Compare commits
184 Commits
111-transa
...
fad756ef05
| Author | SHA1 | Date | |
|---|---|---|---|
| fad756ef05 | |||
| 08b0a7b6fa | |||
| 41d3d04fd7 | |||
| 9d094244ad | |||
|
ae00022f49
|
|||
| 0775f81142 | |||
| 8468fd4293 | |||
| 5afaf3ecdb | |||
| a5d21bfc66 | |||
| d7dcfa7088 | |||
| 2423ed6314 | |||
| 77c901fb78 | |||
| 7de9aa6452 | |||
| 0bc8812a92 | |||
| e82295a4c6 | |||
| d7e6993049 | |||
| 3cfc007f36 | |||
| 7c7566497b | |||
| b0303c224d | |||
| 94de96847c | |||
| c1ee572856 | |||
| 8ee63c6b90 | |||
|
0d56d86a41
|
|||
|
a570c44d75
|
|||
|
5af5ab2a0c
|
|||
|
f1e0c1c1c2
|
|||
|
b13712b0df
|
|||
|
70d6110bc4
|
|||
|
faf28b559a
|
|||
|
39f196341f
|
|||
|
5efba04f1b
|
|||
|
238ec6d55d
|
|||
| 76fdafc709 | |||
|
7b95216987
|
|||
|
209af10fd4
|
|||
|
029c01cd32
|
|||
|
cee01c9a29
|
|||
| 43e4334201 | |||
|
2bbfe7b175
|
|||
|
b5ab697cca
|
|||
|
ada411e1eb
|
|||
|
818dab401e
|
|||
|
1be6d9cb11
|
|||
|
2b320986fd
|
|||
|
d7dbca8242
|
|||
|
fbb6758e57
|
|||
|
2ac14c84cc
|
|||
|
1be46780bb
|
|||
| 6de8d8fb10 | |||
| 423629c7ee | |||
| 09fed02474 | |||
| fe5bf72a03 | |||
| 20ff57a24d | |||
| b2fb257a57 | |||
| 923726f6fa | |||
| 7c78091027 | |||
| 11914db84f | |||
|
05e63faf50
|
|||
|
28113d27d0
|
|||
|
0325fe101c
|
|||
|
ea2663a53d
|
|||
|
2b23700c84
|
|||
|
c927d917ec
|
|||
|
5e563f2c59
|
|||
|
75433834ed
|
|||
|
f9a5a9e5f9
|
|||
|
1e61b765ae
|
|||
| 677c6b795e | |||
| 3b0ba91b73 | |||
| 74b63bc494 | |||
| 480f311856 | |||
| 1a0524a24b | |||
| 2e641a1db5 | |||
| d952956a8d | |||
| b28b41aff4 | |||
| 18e651babf | |||
| 5e992873cc | |||
| 26b75d3db9 | |||
| 772e3e5c2e | |||
| fcba476a88 | |||
| d1bdf38227 | |||
| dea1b9027b | |||
| ee9ef98fa3 | |||
| 9a48f23a2c | |||
| 99d52aa505 | |||
| 8833147278 | |||
| 69727339aa | |||
| e97b7c3069 | |||
| 492baab18b | |||
| 263ea213cd | |||
| 6881a64691 | |||
| 5efd5f9bbc | |||
| 396c97516d | |||
| 88caf44fc5 | |||
| c8dce6f33a | |||
| 12affdff43 | |||
| 9f3fcc0171 | |||
| f5dd96cf9f | |||
| d3900957c9 | |||
| f90c5f83e1 | |||
| a5246e523c | |||
| 11a620b73a | |||
| 5db923d438 | |||
| e1c4eeb51d | |||
| cf728abe11 | |||
| 84f72a1e25 | |||
| 3d2dbaebc7 | |||
| 0b9d1d31e4 | |||
| d3daa4e5ba | |||
| 2839a2c4c3 | |||
|
d3ce7d5ac3
|
|||
| 6e5b4a7b3d | |||
| cc76a77b31 | |||
| 2ac272582f | |||
| 10240977ca | |||
| a7258f6c91 | |||
| 63701f44f5 | |||
| ad1811e37c | |||
| 689dba2f1a | |||
| 7b5fb9e35a | |||
| 237d26675d | |||
| 7ec60b0f93 | |||
| fcb76ae7a8 | |||
| 37525ac31f | |||
| 6b11355857 | |||
| 7d8f6fd1e5 | |||
| 36af297210 | |||
| b05835dde9 | |||
| ff8bd828ec | |||
| 4ec8959db8 | |||
| 1d2a6d1c3a | |||
| 67996068d1 | |||
| 3e8723e359 | |||
| 6c49f0311f | |||
| adf68a8e11 | |||
| a2a88381cb | |||
| e2ddb0b07b | |||
| 1905e5cc03 | |||
| a5e334f4ac | |||
| dc1f9e7a19 | |||
| cd248472f4 | |||
| 76311c3603 | |||
| bba3b32bea | |||
| 73cd04015c | |||
| a9c4304ef8 | |||
| 953d53e884 | |||
| 65b6223256 | |||
| 65fca6390f | |||
| 0394a04c3f | |||
| 29dfd4fa75 | |||
| 12a0ef8c92 | |||
|
65d70fd6df
|
|||
| 278630f2e9 | |||
| bca9563525 | |||
| 8e747efe5f | |||
| 37e5348d7e | |||
| 8e2b4f17aa | |||
| d9be074ef3 | |||
| 037ae74272 | |||
| fd42aa1160 | |||
| b00c93262c | |||
| 68431436fc | |||
| e59541a524 | |||
| 101069b2a6 | |||
| 3759fd8d71 | |||
| aa5636e361 | |||
| ddaf7b8368 | |||
| 49e9b31a2d | |||
| 5944208ca2 | |||
| 95767a8127 | |||
| e16aec5f98 | |||
| 08dcc486d3 | |||
| 0e130aeee4 | |||
| 01101fc2dd | |||
| caedc4ce90 | |||
| 8a3615b612 | |||
| efb1475f11 | |||
| b163495059 | |||
| 24ede772c9 | |||
| 3aca37839c | |||
| 73449d495e | |||
| 7652f823c8 | |||
| 63c2594cbe | |||
| 52693e2846 |
@@ -10,6 +10,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- run: docker build . -t spend-sparrow-test
|
- run: docker build . -t spend-sparrow-test
|
||||||
- run: docker rmi spend-sparrow-test
|
- run: docker rmi spend-sparrow-test
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
||||||
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
|
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
|
||||||
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest
|
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ tmp/
|
|||||||
|
|
||||||
mocks/*
|
mocks/*
|
||||||
!mocks/default.go
|
!mocks/default.go
|
||||||
|
|
||||||
|
arch.png
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ linters:
|
|||||||
- bodyclose # i don't care in the tests, the implementation itself doesn't do http requests
|
- bodyclose # i don't care in the tests, the implementation itself doesn't do http requests
|
||||||
- wsl_v5
|
- wsl_v5
|
||||||
- noinlineerr
|
- noinlineerr
|
||||||
|
- unqueryvet
|
||||||
settings:
|
settings:
|
||||||
nestif:
|
nestif:
|
||||||
min-complexity: 6
|
min-complexity: 6
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ dir: mocks/
|
|||||||
outpkg: mocks
|
outpkg: mocks
|
||||||
issue-845-fix: True
|
issue-845-fix: True
|
||||||
packages:
|
packages:
|
||||||
spend-sparrow/internal/service:
|
spend-sparrow/internal/core:
|
||||||
interfaces:
|
interfaces:
|
||||||
Random:
|
Random:
|
||||||
Clock:
|
Clock:
|
||||||
Mail:
|
Mail:
|
||||||
spend-sparrow/internal/db:
|
spend-sparrow/internal/authentication:
|
||||||
interfaces:
|
interfaces:
|
||||||
Auth:
|
Db:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.25.0@sha256:4859242e2c392ddc9d3225fd41181c00a443d9cc005b8e5131ce164106fbc676 AS builder_go
|
FROM golang:1.26.0@sha256:c83e68f3ebb6943a2904fa66348867d108119890a2c6a2e6f07b38d0eb6c25c5 AS builder_go
|
||||||
WORKDIR /spend-sparrow
|
WORKDIR /spend-sparrow
|
||||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
@@ -13,7 +13,7 @@ RUN golangci-lint run ./...
|
|||||||
RUN go build -o /spend-sparrow/spend-sparrow .
|
RUN go build -o /spend-sparrow/spend-sparrow .
|
||||||
|
|
||||||
|
|
||||||
FROM node:22.18.0@sha256:3266bc9e8bee1acc8a77386eefaf574987d2729b8c5ec35b0dbd6ddbc40b0ce2 AS builder_node
|
FROM node:24.13.1@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 AS builder_node
|
||||||
WORKDIR /spend-sparrow
|
WORKDIR /spend-sparrow
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm clean-install
|
RUN npm clean-install
|
||||||
@@ -21,7 +21,7 @@ COPY . ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM debian:13.0@sha256:6d87375016340817ac2391e670971725a9981cfc24e221c47734681ed0f6c0f5
|
FROM debian:13.3@sha256:2c91e484d93f0830a7e05a2b9d92a7b102be7cab562198b984a84fdbc7806d91
|
||||||
WORKDIR /spend-sparrow
|
WORKDIR /spend-sparrow
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
||||||
COPY migration ./migration
|
COPY migration ./migration
|
||||||
|
|||||||
32
arch.gv
Normal file
32
arch.gv
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
digraph {
|
||||||
|
Tag
|
||||||
|
Transaction
|
||||||
|
Account
|
||||||
|
BankConnection
|
||||||
|
Analytics
|
||||||
|
|
||||||
|
// Buckets
|
||||||
|
Budget
|
||||||
|
RecurringCost
|
||||||
|
SavingGoal
|
||||||
|
|
||||||
|
|
||||||
|
// Analytics -> {
|
||||||
|
// Budget
|
||||||
|
// RecurringCost
|
||||||
|
// SavingGoal
|
||||||
|
// Tag
|
||||||
|
// Transaction
|
||||||
|
// Account
|
||||||
|
// } [label="uses"]
|
||||||
|
|
||||||
|
BankConnection -> Transaction [label="imports into"]
|
||||||
|
BankConnection -> Account [label="references"]
|
||||||
|
|
||||||
|
Transaction -> Account [label="references"]
|
||||||
|
Transaction -> Tag [label="references"]
|
||||||
|
|
||||||
|
Budget -> Tag [label="references"]
|
||||||
|
RecurringCost -> Tag [label="references"]
|
||||||
|
SavingGoal -> Tag [label="references"]
|
||||||
|
}
|
||||||
12
dev.sh
12
dev.sh
@@ -1,12 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||||
go install github.com/a-h/templ/cmd/templ@latest
|
go install github.com/a-h/templ/cmd/templ@latest
|
||||||
go install github.com/vektra/mockery/v2@latest
|
go install github.com/vektra/mockery/v2@latest
|
||||||
|
|
||||||
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." &
|
templ generate --watch --cmd="go run ." &
|
||||||
|
# proxy currently not working with gzip?
|
||||||
|
# templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." &
|
||||||
|
xdg-open http://localhost:8080
|
||||||
npm run watch
|
npm run watch
|
||||||
|
|
||||||
read -n1 -s
|
read -n1 -s -r
|
||||||
kill $(jobs -p)
|
kill "$(jobs -p)"
|
||||||
|
|
||||||
|
|||||||
70
go.mod
70
go.mod
@@ -1,55 +1,53 @@
|
|||||||
module spend-sparrow
|
module spend-sparrow
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.25.0
|
toolchain go1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.943
|
github.com/a-h/templ v0.3.977
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.34
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
|
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
|
||||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
|
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
|
||||||
go.opentelemetry.io/otel v1.37.0
|
go.opentelemetry.io/otel v1.39.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
|
||||||
go.opentelemetry.io/otel/log v0.13.0
|
go.opentelemetry.io/otel/log v0.15.0
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0
|
go.opentelemetry.io/otel/sdk v1.39.0
|
||||||
go.opentelemetry.io/otel/sdk/log v0.13.0
|
go.opentelemetry.io/otel/sdk/log v0.15.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0
|
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||||
go.opentelemetry.io/otel/trace v1.37.0
|
go.opentelemetry.io/otel/trace v1.39.0
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/net v0.43.0
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
google.golang.org/grpc v1.73.0 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
143
go.sum
143
go.sum
@@ -1,11 +1,13 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
|
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||||
github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
@@ -15,21 +17,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
@@ -41,72 +38,72 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
|
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
|
||||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
|
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
|
||||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
|
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
|
||||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU=
|
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 h1:z6lNIajgEBVtQZHjfw2hAccPEBDs+nx58VemmXWa2ec=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0/go.mod h1:+kyc3bRx/Qkq05P6OCu3mTEIOxYRYzoIg+JsUp5X+PM=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
|
||||||
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
|
go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
|
||||||
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
|
go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ=
|
go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw=
|
go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -1,105 +1,96 @@
|
|||||||
package handler
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"spend-sparrow/internal/handler/middleware"
|
|
||||||
"spend-sparrow/internal/service"
|
|
||||||
t "spend-sparrow/internal/template/account"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Account interface {
|
type Handler struct {
|
||||||
Handle(router *http.ServeMux)
|
s Service
|
||||||
|
r *core.Render
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountImpl struct {
|
func NewHandler(s Service, r *core.Render) Handler {
|
||||||
s service.Account
|
return Handler{
|
||||||
r *Render
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAccount(s service.Account, r *Render) Account {
|
|
||||||
return AccountImpl{
|
|
||||||
s: s,
|
s: s,
|
||||||
r: r,
|
r: r,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h AccountImpl) Handle(r *http.ServeMux) {
|
func (h Handler) Handle(r *http.ServeMux) {
|
||||||
r.Handle("GET /account", h.handleAccountPage())
|
r.Handle("GET /account", h.handleAccountPage())
|
||||||
r.Handle("GET /account/{id}", h.handleAccountItemComp())
|
r.Handle("GET /account/{id}", h.handleAccountItemComp())
|
||||||
r.Handle("POST /account/{id}", h.handleUpdateAccount())
|
r.Handle("POST /account/{id}", h.handleUpdateAccount())
|
||||||
r.Handle("DELETE /account/{id}", h.handleDeleteAccount())
|
r.Handle("DELETE /account/{id}", h.handleDeleteAccount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h AccountImpl) handleAccountPage() http.HandlerFunc {
|
func (h Handler) handleAccountPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, err := h.s.GetAll(r.Context(), user)
|
accounts, err := h.s.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := t.Account(accounts)
|
comp := template(accounts)
|
||||||
h.r.RenderLayout(r, w, comp, user)
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
|
func (h Handler) handleAccountItemComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if id == "new" {
|
if id == "new" {
|
||||||
comp := t.EditAccount(nil)
|
comp := editAccount(nil)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
account, err := h.s.Get(r.Context(), user, id)
|
account, err := h.s.Get(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var comp templ.Component
|
var comp templ.Component
|
||||||
if r.URL.Query().Get("edit") == "true" {
|
if r.URL.Query().Get("edit") == "true" {
|
||||||
comp = t.EditAccount(account)
|
comp = editAccount(account)
|
||||||
} else {
|
} else {
|
||||||
comp = t.AccountItem(account)
|
comp = accountItem(account)
|
||||||
}
|
}
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
|
func (h Handler) handleUpdateAccount() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
account *types.Account
|
account *Account
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
@@ -107,29 +98,29 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
|
|||||||
if id == "new" {
|
if id == "new" {
|
||||||
account, err = h.s.Add(r.Context(), user, name)
|
account, err = h.s.Add(r.Context(), user, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
account, err = h.s.UpdateName(r.Context(), user, id, name)
|
account, err = h.s.UpdateName(r.Context(), user, id, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := t.AccountItem(account)
|
comp := accountItem(account)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
|
func (h Handler) handleDeleteAccount() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +128,7 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
|
|||||||
|
|
||||||
err := h.s.Delete(r.Context(), user, id)
|
err := h.s.Delete(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,55 +1,55 @@
|
|||||||
package service
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Account interface {
|
type Service interface {
|
||||||
Add(ctx context.Context, user *types.User, name string) (*types.Account, error)
|
Add(ctx context.Context, user *auth_types.User, name string) (*Account, error)
|
||||||
UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error)
|
UpdateName(ctx context.Context, user *auth_types.User, id string, name string) (*Account, error)
|
||||||
Get(ctx context.Context, user *types.User, id string) (*types.Account, error)
|
Get(ctx context.Context, user *auth_types.User, id string) (*Account, error)
|
||||||
GetAll(ctx context.Context, user *types.User) ([]*types.Account, error)
|
GetAll(ctx context.Context, user *auth_types.User) ([]*Account, error)
|
||||||
Delete(ctx context.Context, user *types.User, id string) error
|
Delete(ctx context.Context, user *auth_types.User, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountImpl struct {
|
type ServiceImpl struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
clock Clock
|
clock core.Clock
|
||||||
random Random
|
random core.Random
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccount(db *sqlx.DB, random Random, clock Clock) Account {
|
func NewServiceImpl(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||||
return AccountImpl{
|
return ServiceImpl{
|
||||||
db: db,
|
db: db,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
random: random,
|
random: random,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*types.Account, error) {
|
func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, name string) (*Account, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
newId, err := s.random.UUID(ctx)
|
newId, err := s.random.UUID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateString(name, "name")
|
err = core.ValidateString(name, "name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
account := &types.Account{
|
account := &Account{
|
||||||
Id: newId,
|
Id: newId,
|
||||||
UserId: user.Id,
|
UserId: user.Id,
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*t
|
|||||||
r, err := s.db.NamedExecContext(ctx, `
|
r, err := s.db.NamedExecContext(ctx, `
|
||||||
INSERT INTO account (id, user_id, name, current_balance, oink_balance, created_at, created_by)
|
INSERT INTO account (id, user_id, name, current_balance, oink_balance, created_at, created_by)
|
||||||
VALUES (:id, :user_id, :name, :current_balance, :oink_balance, :created_at, :created_by)`, account)
|
VALUES (:id, :user_id, :name, :current_balance, :oink_balance, :created_at, :created_by)`, account)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Insert", r, err)
|
err = core.TransformAndLogDbError(ctx, "account Insert", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,22 +76,22 @@ func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*t
|
|||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error) {
|
func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id string, name string) (*Account, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
err := validateString(name, "name")
|
err := core.ValidateString(name, "name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "account update", "err", err)
|
slog.ErrorContext(ctx, "account update", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -99,14 +99,14 @@ func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var account types.Account
|
var account Account
|
||||||
err = tx.GetContext(ctx, &account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
err = tx.GetContext(ctx, &account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
|
return nil, fmt.Errorf("account %v not found: %w", id, core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp := s.clock.Now()
|
timestamp := s.clock.Now()
|
||||||
@@ -122,13 +122,13 @@ func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string
|
|||||||
updated_by = :updated_by
|
updated_by = :updated_by
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
AND user_id = :user_id`, account)
|
AND user_id = :user_id`, account)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "account Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -136,20 +136,20 @@ func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string
|
|||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccountImpl) Get(ctx context.Context, user *types.User, id string) (*types.Account, error) {
|
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*Account, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "account get", "err", err)
|
slog.ErrorContext(ctx, "account get", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
var account types.Account
|
var account Account
|
||||||
err = s.db.GetContext(ctx, &account, `
|
err = s.db.GetContext(ctx, &account, `
|
||||||
SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Get", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Get", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "account get", "err", err)
|
slog.ErrorContext(ctx, "account get", "err", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -158,15 +158,15 @@ func (s AccountImpl) Get(ctx context.Context, user *types.User, id string) (*typ
|
|||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccountImpl) GetAll(ctx context.Context, user *types.User) ([]*types.Account, error) {
|
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*Account, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts := make([]*types.Account, 0)
|
accounts := make([]*Account, 0)
|
||||||
err := s.db.SelectContext(ctx, &accounts, `
|
err := s.db.SelectContext(ctx, &accounts, `
|
||||||
SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id)
|
SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "account GetAll", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account GetAll", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -174,18 +174,18 @@ func (s AccountImpl) GetAll(ctx context.Context, user *types.User) ([]*types.Acc
|
|||||||
return accounts, nil
|
return accounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccountImpl) Delete(ctx context.Context, user *types.User, id string) error {
|
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ErrUnauthorized
|
return core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "account delete", "err", err)
|
slog.ErrorContext(ctx, "account delete", "err", err)
|
||||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -195,22 +195,22 @@ func (s AccountImpl) Delete(ctx context.Context, user *types.User, id string) er
|
|||||||
|
|
||||||
transactionsCount := 0
|
transactionsCount := 0
|
||||||
err = tx.GetContext(ctx, &transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid)
|
err = tx.GetContext(ctx, &transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if transactionsCount > 0 {
|
if transactionsCount > 0 {
|
||||||
return fmt.Errorf("account has transactions, cannot delete: %w", ErrBadRequest)
|
return fmt.Errorf("account has transactions, cannot delete: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
|
res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "account Delete", res, err)
|
err = core.TransformAndLogDbError(ctx, "account Delete", res, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import "spend-sparrow/internal/template/svg"
|
import (
|
||||||
import "spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/template/svg"
|
||||||
|
)
|
||||||
|
|
||||||
templ Account(accounts []*types.Account) {
|
templ template(accounts []*Account) {
|
||||||
<div class="max-w-6xl mt-10 mx-auto">
|
<div class="max-w-6xl mt-10 mx-auto">
|
||||||
<button
|
<button
|
||||||
hx-get="/account/new"
|
hx-get="/account/new"
|
||||||
@@ -11,33 +13,35 @@ templ Account(accounts []*types.Account) {
|
|||||||
hx-swap="afterbegin"
|
hx-swap="afterbegin"
|
||||||
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
|
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
|
||||||
>
|
>
|
||||||
@svg.Plus()
|
<div class="w-3">
|
||||||
|
@svg.Plus()
|
||||||
|
</div>
|
||||||
<p>New Account</p>
|
<p>New Account</p>
|
||||||
</button>
|
</button>
|
||||||
<div id="account-items" class="my-6 flex flex-col items-center">
|
<div id="account-items" class="my-6 flex flex-col items-center">
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
@AccountItem(account)
|
@accountItem(account)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ EditAccount(account *types.Account) {
|
templ editAccount(account *Account) {
|
||||||
{{
|
{{
|
||||||
var (
|
var (
|
||||||
name string
|
name string
|
||||||
id string
|
id string
|
||||||
cancelUrl string
|
cancelUrl string
|
||||||
)
|
)
|
||||||
if account == nil {
|
if account == nil {
|
||||||
name = ""
|
name = ""
|
||||||
id = "new"
|
id = "new"
|
||||||
cancelUrl = "/empty"
|
cancelUrl = "/empty"
|
||||||
} else {
|
} else {
|
||||||
name = account.Name
|
name = account.Name
|
||||||
id = account.Id.String()
|
id = account.Id.String()
|
||||||
cancelUrl = "/account/" + id
|
cancelUrl = "/account/" + id
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
||||||
<form
|
<form
|
||||||
@@ -77,14 +81,14 @@ templ EditAccount(account *types.Account) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ AccountItem(account *types.Account) {
|
templ accountItem(account *Account) {
|
||||||
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
||||||
<div class="text-xl flex justify-end gap-4">
|
<div class="text-xl flex justify-end gap-4">
|
||||||
<p class="mr-auto">{ account.Name }</p>
|
<p class="mr-auto">{ account.Name }</p>
|
||||||
if account.CurrentBalance < 0 {
|
if account.CurrentBalance < 0 {
|
||||||
<p class="mr-20 text-red-700">{ types.FormatEuros(account.CurrentBalance) }</p>
|
<p class="mr-20 text-red-700">{ core.FormatEuros(account.CurrentBalance) }</p>
|
||||||
} else {
|
} else {
|
||||||
<p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p>
|
<p class="mr-20 text-green-700">{ core.FormatEuros(account.CurrentBalance) }</p>
|
||||||
}
|
}
|
||||||
<a
|
<a
|
||||||
href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
|
href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package types
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Account holds money.
|
// The Account holds money.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package auth_types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package db
|
package authentication
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/auth_types"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,36 +14,36 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Auth interface {
|
type Db interface {
|
||||||
InsertUser(ctx context.Context, user *types.User) error
|
InsertUser(ctx context.Context, user *auth_types.User) error
|
||||||
UpdateUser(ctx context.Context, user *types.User) error
|
UpdateUser(ctx context.Context, user *auth_types.User) error
|
||||||
GetUserByEmail(ctx context.Context, email string) (*types.User, error)
|
GetUserByEmail(ctx context.Context, email string) (*auth_types.User, error)
|
||||||
GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error)
|
GetUser(ctx context.Context, userId uuid.UUID) (*auth_types.User, error)
|
||||||
DeleteUser(ctx context.Context, userId uuid.UUID) error
|
DeleteUser(ctx context.Context, userId uuid.UUID) error
|
||||||
|
|
||||||
InsertToken(ctx context.Context, token *types.Token) error
|
InsertToken(ctx context.Context, token *auth_types.Token) error
|
||||||
GetToken(ctx context.Context, token string) (*types.Token, error)
|
GetToken(ctx context.Context, token string) (*auth_types.Token, error)
|
||||||
GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
|
GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType auth_types.TokenType) ([]*auth_types.Token, error)
|
||||||
GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error)
|
GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType auth_types.TokenType) ([]*auth_types.Token, error)
|
||||||
DeleteToken(ctx context.Context, token string) error
|
DeleteToken(ctx context.Context, token string) error
|
||||||
|
|
||||||
InsertSession(ctx context.Context, session *types.Session) error
|
InsertSession(ctx context.Context, session *auth_types.Session) error
|
||||||
GetSession(ctx context.Context, sessionId string) (*types.Session, error)
|
GetSession(ctx context.Context, sessionId string) (*auth_types.Session, error)
|
||||||
GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error)
|
GetSessions(ctx context.Context, userId uuid.UUID) ([]*auth_types.Session, error)
|
||||||
DeleteSession(ctx context.Context, sessionId string) error
|
DeleteSession(ctx context.Context, sessionId string) error
|
||||||
DeleteOldSessions(ctx context.Context) error
|
DeleteOldSessions(ctx context.Context) error
|
||||||
DeleteOldTokens(ctx context.Context) error
|
DeleteOldTokens(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthSqlite struct {
|
type DbSqlite struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthSqlite(db *sqlx.DB) *AuthSqlite {
|
func NewDbSqlite(db *sqlx.DB) *DbSqlite {
|
||||||
return &AuthSqlite{db: db}
|
return &DbSqlite{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) InsertUser(ctx context.Context, user *types.User) error {
|
func (db DbSqlite) InsertUser(ctx context.Context, user *auth_types.User) error {
|
||||||
_, err := db.db.ExecContext(ctx, `
|
_, err := db.db.ExecContext(ctx, `
|
||||||
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
|
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -50,17 +51,17 @@ func (db AuthSqlite) InsertUser(ctx context.Context, user *types.User) error {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "email") {
|
if strings.Contains(err.Error(), "email") {
|
||||||
return ErrAlreadyExists
|
return core.ErrAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.ErrorContext(ctx, "SQL error InsertUser", "err", err)
|
slog.ErrorContext(ctx, "SQL error InsertUser", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) UpdateUser(ctx context.Context, user *types.User) error {
|
func (db DbSqlite) UpdateUser(ctx context.Context, user *auth_types.User) error {
|
||||||
_, err := db.db.ExecContext(ctx, `
|
_, err := db.db.ExecContext(ctx, `
|
||||||
UPDATE user
|
UPDATE user
|
||||||
SET email_verified = ?, email_verified_at = ?, password = ?
|
SET email_verified = ?, email_verified_at = ?, password = ?
|
||||||
@@ -69,13 +70,13 @@ func (db AuthSqlite) UpdateUser(ctx context.Context, user *types.User) error {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
|
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetUserByEmail(ctx context.Context, email string) (*types.User, error) {
|
func (db DbSqlite) GetUserByEmail(ctx context.Context, email string) (*auth_types.User, error) {
|
||||||
var (
|
var (
|
||||||
userId uuid.UUID
|
userId uuid.UUID
|
||||||
emailVerified bool
|
emailVerified bool
|
||||||
@@ -92,17 +93,17 @@ func (db AuthSqlite) GetUserByEmail(ctx context.Context, email string) (*types.U
|
|||||||
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, core.ErrNotFound
|
||||||
} else {
|
} else {
|
||||||
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
|
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
return auth_types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error) {
|
func (db DbSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*auth_types.User, error) {
|
||||||
var (
|
var (
|
||||||
email string
|
email string
|
||||||
emailVerified bool
|
emailVerified bool
|
||||||
@@ -119,92 +120,92 @@ func (db AuthSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*types.User
|
|||||||
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, core.ErrNotFound
|
||||||
} else {
|
} else {
|
||||||
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
|
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
return auth_types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) DeleteUser(ctx context.Context, userId uuid.UUID) error {
|
func (db DbSqlite) DeleteUser(ctx context.Context, userId uuid.UUID) error {
|
||||||
tx, err := db.db.BeginTx(ctx, nil)
|
tx, err := db.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not start transaction", "err", err)
|
slog.ErrorContext(ctx, "Could not start transaction", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM account WHERE user_id = ?", userId)
|
_, err = tx.ExecContext(ctx, "DELETE FROM account WHERE user_id = ?", userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
slog.ErrorContext(ctx, "Could not delete accounts", "err", err)
|
slog.ErrorContext(ctx, "Could not delete accounts", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM token WHERE user_id = ?", userId)
|
_, err = tx.ExecContext(ctx, "DELETE FROM token WHERE user_id = ?", userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
slog.ErrorContext(ctx, "Could not delete user tokens", "err", err)
|
slog.ErrorContext(ctx, "Could not delete user tokens", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM session WHERE user_id = ?", userId)
|
_, err = tx.ExecContext(ctx, "DELETE FROM session WHERE user_id = ?", userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
slog.ErrorContext(ctx, "Could not delete sessions", "err", err)
|
slog.ErrorContext(ctx, "Could not delete sessions", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM user WHERE user_id = ?", userId)
|
_, err = tx.ExecContext(ctx, "DELETE FROM user WHERE user_id = ?", userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
slog.ErrorContext(ctx, "Could not delete user", "err", err)
|
slog.ErrorContext(ctx, "Could not delete user", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM treasure_chest WHERE user_id = ?", userId)
|
_, err = tx.ExecContext(ctx, "DELETE FROM treasure_chest WHERE user_id = ?", userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
slog.ErrorContext(ctx, "Could not delete user", "err", err)
|
slog.ErrorContext(ctx, "Could not delete user", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE user_id = ?", userId)
|
_, err = tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE user_id = ?", userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
slog.ErrorContext(ctx, "Could not delete user", "err", err)
|
slog.ErrorContext(ctx, "Could not delete user", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not commit transaction", "err", err)
|
slog.ErrorContext(ctx, "Could not commit transaction", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) InsertToken(ctx context.Context, token *types.Token) error {
|
func (db DbSqlite) InsertToken(ctx context.Context, token *auth_types.Token) error {
|
||||||
_, err := db.db.ExecContext(ctx, `
|
_, err := db.db.ExecContext(ctx, `
|
||||||
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
|
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
|
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not insert token", "err", err)
|
slog.ErrorContext(ctx, "Could not insert token", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetToken(ctx context.Context, token string) (*types.Token, error) {
|
func (db DbSqlite) GetToken(ctx context.Context, token string) (*auth_types.Token, error) {
|
||||||
var (
|
var (
|
||||||
userId uuid.UUID
|
userId uuid.UUID
|
||||||
sessionId string
|
sessionId string
|
||||||
tokenType types.TokenType
|
tokenType auth_types.TokenType
|
||||||
createdAtStr string
|
createdAtStr string
|
||||||
expiresAtStr string
|
expiresAtStr string
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
@@ -219,29 +220,29 @@ func (db AuthSqlite) GetToken(ctx context.Context, token string) (*types.Token,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
slog.InfoContext(ctx, "Token not found", "token", token)
|
slog.InfoContext(ctx, "Token not found", "token", token)
|
||||||
return nil, ErrNotFound
|
return nil, core.ErrNotFound
|
||||||
} else {
|
} else {
|
||||||
slog.ErrorContext(ctx, "Could not get token", "err", err)
|
slog.ErrorContext(ctx, "Could not get token", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
|
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
|
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
|
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
|
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
|
return auth_types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
|
func (db DbSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType auth_types.TokenType) ([]*auth_types.Token, error) {
|
||||||
query, err := db.db.QueryContext(ctx, `
|
query, err := db.db.QueryContext(ctx, `
|
||||||
SELECT token, created_at, expires_at
|
SELECT token, created_at, expires_at
|
||||||
FROM token
|
FROM token
|
||||||
@@ -250,13 +251,13 @@ func (db AuthSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.U
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not get token", "err", err)
|
slog.ErrorContext(ctx, "Could not get token", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return getTokensFromQuery(ctx, query, userId, "", tokenType)
|
return getTokensFromQuery(ctx, query, userId, "", tokenType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
|
func (db DbSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType auth_types.TokenType) ([]*auth_types.Token, error) {
|
||||||
query, err := db.db.QueryContext(ctx, `
|
query, err := db.db.QueryContext(ctx, `
|
||||||
SELECT token, created_at, expires_at
|
SELECT token, created_at, expires_at
|
||||||
FROM token
|
FROM token
|
||||||
@@ -265,14 +266,14 @@ func (db AuthSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not get token", "err", err)
|
slog.ErrorContext(ctx, "Could not get token", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return getTokensFromQuery(ctx, query, uuid.Nil, sessionId, tokenType)
|
return getTokensFromQuery(ctx, query, uuid.Nil, sessionId, tokenType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTokensFromQuery(ctx context.Context, query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
|
func getTokensFromQuery(ctx context.Context, query *sql.Rows, userId uuid.UUID, sessionId string, tokenType auth_types.TokenType) ([]*auth_types.Token, error) {
|
||||||
var tokens []*types.Token
|
var tokens []*auth_types.Token
|
||||||
|
|
||||||
hasRows := false
|
hasRows := false
|
||||||
for query.Next() {
|
for query.Next() {
|
||||||
@@ -289,54 +290,54 @@ func getTokensFromQuery(ctx context.Context, query *sql.Rows, userId uuid.UUID,
|
|||||||
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
|
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not scan token", "err", err)
|
slog.ErrorContext(ctx, "Could not scan token", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
|
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
|
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
|
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
|
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
|
tokens = append(tokens, auth_types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasRows {
|
if !hasRows {
|
||||||
return nil, ErrNotFound
|
return nil, core.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokens, nil
|
return tokens, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) DeleteToken(ctx context.Context, token string) error {
|
func (db DbSqlite) DeleteToken(ctx context.Context, token string) error {
|
||||||
_, err := db.db.ExecContext(ctx, "DELETE FROM token WHERE token = ?", token)
|
_, err := db.db.ExecContext(ctx, "DELETE FROM token WHERE token = ?", token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not delete token", "err", err)
|
slog.ErrorContext(ctx, "Could not delete token", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) InsertSession(ctx context.Context, session *types.Session) error {
|
func (db DbSqlite) InsertSession(ctx context.Context, session *auth_types.Session) error {
|
||||||
_, err := db.db.ExecContext(ctx, `
|
_, err := db.db.ExecContext(ctx, `
|
||||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
|
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not insert new session", "err", err)
|
slog.ErrorContext(ctx, "Could not insert new session", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetSession(ctx context.Context, sessionId string) (*types.Session, error) {
|
func (db DbSqlite) GetSession(ctx context.Context, sessionId string) (*auth_types.Session, error) {
|
||||||
var (
|
var (
|
||||||
userId uuid.UUID
|
userId uuid.UUID
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
@@ -350,56 +351,56 @@ func (db AuthSqlite) GetSession(ctx context.Context, sessionId string) (*types.S
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.WarnContext(ctx, "Session not found", "session-id", sessionId, "err", err)
|
slog.WarnContext(ctx, "Session not found", "session-id", sessionId, "err", err)
|
||||||
return nil, ErrNotFound
|
return nil, core.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
|
return auth_types.NewSession(sessionId, userId, createdAt, expiresAt), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error) {
|
func (db DbSqlite) GetSessions(ctx context.Context, userId uuid.UUID) ([]*auth_types.Session, error) {
|
||||||
var sessions []*types.Session
|
var sessions []*auth_types.Session
|
||||||
err := db.db.SelectContext(ctx, &sessions, `
|
err := db.db.SelectContext(ctx, &sessions, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM session
|
FROM session
|
||||||
WHERE user_id = ?`, userId)
|
WHERE user_id = ?`, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not get sessions", "err", err)
|
slog.ErrorContext(ctx, "Could not get sessions", "err", err)
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions, nil
|
return sessions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) DeleteSession(ctx context.Context, sessionId string) error {
|
func (db DbSqlite) DeleteSession(ctx context.Context, sessionId string) error {
|
||||||
if sessionId != "" {
|
if sessionId != "" {
|
||||||
_, err := db.db.ExecContext(ctx, "DELETE FROM session WHERE session_id = ?", sessionId)
|
_, err := db.db.ExecContext(ctx, "DELETE FROM session WHERE session_id = ?", sessionId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not delete session", "err", err)
|
slog.ErrorContext(ctx, "Could not delete session", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) DeleteOldSessions(ctx context.Context) error {
|
func (db DbSqlite) DeleteOldSessions(ctx context.Context) error {
|
||||||
_, err := db.db.ExecContext(ctx, `
|
_, err := db.db.ExecContext(ctx, `
|
||||||
DELETE FROM session
|
DELETE FROM session
|
||||||
WHERE expires_at < datetime('now')`)
|
WHERE expires_at < datetime('now')`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not delete old sessions", "err", err)
|
slog.ErrorContext(ctx, "Could not delete old sessions", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db AuthSqlite) DeleteOldTokens(ctx context.Context) error {
|
func (db DbSqlite) DeleteOldTokens(ctx context.Context) error {
|
||||||
_, err := db.db.ExecContext(ctx, `
|
_, err := db.db.ExecContext(ctx, `
|
||||||
DELETE FROM token
|
DELETE FROM token
|
||||||
WHERE expires_at < datetime('now')`)
|
WHERE expires_at < datetime('now')`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not delete old tokens", "err", err)
|
slog.ErrorContext(ctx, "Could not delete old tokens", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,33 @@
|
|||||||
package handler
|
package authentication
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"spend-sparrow/internal/handler/middleware"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/authentication/template"
|
||||||
"spend-sparrow/internal/template/auth"
|
"spend-sparrow/internal/core"
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Auth interface {
|
type Handler interface {
|
||||||
Handle(router *http.ServeMux)
|
Handle(router *http.ServeMux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthImpl struct {
|
type HandlerImpl struct {
|
||||||
service service.Auth
|
service Service
|
||||||
render *Render
|
render *core.Render
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuth(service service.Auth, render *Render) Auth {
|
func NewHandler(service Service, render *core.Render) Handler {
|
||||||
return AuthImpl{
|
return HandlerImpl{
|
||||||
service: service,
|
service: service,
|
||||||
render: render,
|
render: render,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) Handle(router *http.ServeMux) {
|
func (handler HandlerImpl) Handle(router *http.ServeMux) {
|
||||||
router.Handle("GET /auth/signin", handler.handleSignInPage())
|
router.Handle("GET /auth/signin", handler.handleSignInPage())
|
||||||
router.Handle("POST /api/auth/signin", handler.handleSignIn())
|
router.Handle("POST /api/auth/signin", handler.handleSignIn())
|
||||||
|
|
||||||
@@ -56,32 +54,32 @@ var (
|
|||||||
securityWaitDuration = 250 * time.Millisecond
|
securityWaitDuration = 250 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignInPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
if !user.EmailVerified {
|
if !user.EmailVerified {
|
||||||
utils.DoRedirect(w, r, "/auth/verify")
|
core.DoRedirect(w, r, "/auth/verify")
|
||||||
} else {
|
} else {
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := auth.SignInOrUpComp(true)
|
comp := template.SignInOrUpComp(true)
|
||||||
|
|
||||||
handler.render.RenderLayout(r, w, comp, nil)
|
handler.render.RenderLayout(r, w, comp, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
|
user, err := core.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) {
|
||||||
session := middleware.GetSession(r)
|
session := core.GetSession(r)
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
||||||
@@ -90,76 +88,76 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie := middleware.CreateSessionCookie(session.Id)
|
cookie := core.CreateSessionCookie(session.Id)
|
||||||
http.SetCookie(w, &cookie)
|
http.SetCookie(w, &cookie)
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
if errors.Is(err, ErrInvalidCredentials) {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.EmailVerified {
|
if user.EmailVerified {
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
} else {
|
} else {
|
||||||
utils.DoRedirect(w, r, "/auth/verify")
|
core.DoRedirect(w, r, "/auth/verify")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignUpPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
|
|
||||||
if user != nil {
|
if user != nil {
|
||||||
if !user.EmailVerified {
|
if !user.EmailVerified {
|
||||||
utils.DoRedirect(w, r, "/auth/verify")
|
core.DoRedirect(w, r, "/auth/verify")
|
||||||
} else {
|
} else {
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signUpComp := auth.SignInOrUpComp(false)
|
signUpComp := template.SignInOrUpComp(false)
|
||||||
handler.render.RenderLayout(r, w, signUpComp, nil)
|
handler.render.RenderLayout(r, w, signUpComp, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.EmailVerified {
|
if user.EmailVerified {
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signIn := auth.VerifyComp()
|
signIn := template.VerifyComp()
|
||||||
handler.render.RenderLayout(r, w, signIn, user)
|
handler.render.RenderLayout(r, w, signIn, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
|
func (handler HandlerImpl) handleVerifyResendComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,16 +170,16 @@ func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
|
|
||||||
err := handler.service.VerifyUserEmail(r.Context(), token)
|
err := handler.service.VerifyUserEmail(r.Context(), token)
|
||||||
|
|
||||||
isVerified := err == nil
|
isVerified := err == nil
|
||||||
comp := auth.VerifyResponseComp(isVerified)
|
comp := template.VerifyResponseComp(isVerified)
|
||||||
|
|
||||||
var status int
|
var status int
|
||||||
if isVerified {
|
if isVerified {
|
||||||
@@ -194,14 +192,14 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
var email = r.FormValue("email")
|
var email = r.FormValue("email")
|
||||||
var password = r.FormValue("password")
|
var password = r.FormValue("password")
|
||||||
|
|
||||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
_, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||||
slog.InfoContext(r.Context(), "signing up", "email", email)
|
slog.InfoContext(r.Context(), "signing up", "email", email)
|
||||||
user, err := handler.service.SignUp(r.Context(), email, password)
|
user, err := handler.service.SignUp(r.Context(), email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,28 +213,28 @@ func (handler AuthImpl) handleSignUp() http.HandlerFunc {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, types.ErrInternal):
|
case errors.Is(err, core.ErrInternal):
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
case errors.Is(err, service.ErrInvalidEmail):
|
case errors.Is(err, ErrInvalidEmail):
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
case errors.Is(err, service.ErrInvalidPassword):
|
case errors.Is(err, ErrInvalidPassword):
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", ErrInvalidPassword.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If err is "service.ErrAccountExists", then just continue
|
// If err is "service.ErrAccountExists", then just continue
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
core.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
func (handler HandlerImpl) handleSignOut() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
session := middleware.GetSession(r)
|
session := core.GetSession(r)
|
||||||
|
|
||||||
if session != nil {
|
if session != nil {
|
||||||
err := handler.service.SignOut(r.Context(), session.Id)
|
err := handler.service.SignOut(r.Context(), session.Id)
|
||||||
@@ -257,32 +255,32 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &c)
|
http.SetCookie(w, &c)
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
func (handler HandlerImpl) handleDeleteAccountPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := auth.DeleteAccountComp()
|
comp := template.DeleteAccountComp()
|
||||||
handler.render.RenderLayout(r, w, comp, user)
|
handler.render.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,44 +288,44 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
|||||||
|
|
||||||
err := handler.service.DeleteAccount(r.Context(), user, password)
|
err := handler.service.DeleteAccount(r.Context(), user, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
if errors.Is(err, ErrInvalidCredentials) {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
func (handler HandlerImpl) handleChangePasswordPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
isPasswordReset := r.URL.Query().Has("token")
|
isPasswordReset := r.URL.Query().Has("token")
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
|
|
||||||
if user == nil && !isPasswordReset {
|
if user == nil && !isPasswordReset {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := auth.ChangePasswordComp(isPasswordReset)
|
comp := template.ChangePasswordComp(isPasswordReset)
|
||||||
handler.render.RenderLayout(r, w, comp, user)
|
handler.render.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
session := middleware.GetSession(r)
|
session := core.GetSession(r)
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if session == nil || user == nil {
|
if session == nil || user == nil {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,60 +334,60 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
|||||||
|
|
||||||
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
|
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
|
func (handler HandlerImpl) handleForgotPasswordPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
utils.DoRedirect(w, r, "/")
|
core.DoRedirect(w, r, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := auth.ResetPasswordComp()
|
comp := template.ResetPasswordComp()
|
||||||
handler.render.RenderLayout(r, w, comp, user)
|
handler.render.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
|
func (handler HandlerImpl) handleForgotPasswordComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
if email == "" {
|
if email == "" {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
_, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||||
err := handler.service.SendForgotPasswordMail(r.Context(), email)
|
err := handler.service.SendForgotPasswordMail(r.Context(), email)
|
||||||
return nil, err
|
return nil, err
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
core.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
|
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
|
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,9 +396,9 @@ func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
|||||||
|
|
||||||
err = handler.service.ForgotPassword(r.Context(), token, newPass)
|
err = handler.service.ForgotPassword(r.Context(), token, newPass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package authentication
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
mailTemplate "spend-sparrow/internal/template/mail"
|
mailTemplate "spend-sparrow/internal/template/mail"
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,39 +25,39 @@ var (
|
|||||||
ErrTokenInvalid = errors.New("token is invalid")
|
ErrTokenInvalid = errors.New("token is invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Auth interface {
|
type Service interface {
|
||||||
SignUp(ctx context.Context, email string, password string) (*types.User, error)
|
SignUp(ctx context.Context, email string, password string) (*auth_types.User, error)
|
||||||
SendVerificationMail(ctx context.Context, userId uuid.UUID, email string)
|
SendVerificationMail(ctx context.Context, userId uuid.UUID, email string)
|
||||||
VerifyUserEmail(ctx context.Context, token string) error
|
VerifyUserEmail(ctx context.Context, token string) error
|
||||||
|
|
||||||
SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error)
|
SignIn(ctx context.Context, session *auth_types.Session, email string, password string) (*auth_types.Session, *auth_types.User, error)
|
||||||
SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error)
|
SignInSession(ctx context.Context, sessionId string) (*auth_types.Session, *auth_types.User, error)
|
||||||
SignInAnonymous(ctx context.Context) (*types.Session, error)
|
SignInAnonymous(ctx context.Context) (*auth_types.Session, error)
|
||||||
SignOut(ctx context.Context, sessionId string) error
|
SignOut(ctx context.Context, sessionId string) error
|
||||||
|
|
||||||
DeleteAccount(ctx context.Context, user *types.User, currPass string) error
|
DeleteAccount(ctx context.Context, user *auth_types.User, currPass string) error
|
||||||
|
|
||||||
ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error
|
ChangePassword(ctx context.Context, user *auth_types.User, sessionId string, currPass, newPass string) error
|
||||||
|
|
||||||
SendForgotPasswordMail(ctx context.Context, email string) error
|
SendForgotPasswordMail(ctx context.Context, email string) error
|
||||||
ForgotPassword(ctx context.Context, token string, newPass string) error
|
ForgotPassword(ctx context.Context, token string, newPass string) error
|
||||||
|
|
||||||
IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool
|
IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool
|
||||||
GetCsrfToken(ctx context.Context, session *types.Session) (string, error)
|
GetCsrfToken(ctx context.Context, session *auth_types.Session) (string, error)
|
||||||
|
|
||||||
CleanupSessionsAndTokens(ctx context.Context) error
|
CleanupSessionsAndTokens(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthImpl struct {
|
type ServiceImpl struct {
|
||||||
db db.Auth
|
db Db
|
||||||
random Random
|
random core.Random
|
||||||
clock Clock
|
clock core.Clock
|
||||||
mail Mail
|
mail core.Mail
|
||||||
serverSettings *types.Settings
|
serverSettings *core.Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl {
|
func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *core.Settings) *ServiceImpl {
|
||||||
return &AuthImpl{
|
return &ServiceImpl{
|
||||||
db: db,
|
db: db,
|
||||||
random: random,
|
random: random,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
@@ -66,13 +66,13 @@ func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) {
|
func (service ServiceImpl) SignIn(ctx context.Context, session *auth_types.Session, email string, password string) (*auth_types.Session, *auth_types.User, error) {
|
||||||
user, err := service.db.GetUserByEmail(ctx, email)
|
user, err := service.db.GetUserByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, nil, ErrInvalidCredentials
|
return nil, nil, ErrInvalidCredentials
|
||||||
} else {
|
} else {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,36 +84,36 @@ func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, emai
|
|||||||
|
|
||||||
newSession, err := service.createSession(ctx, user.Id)
|
newSession, err := service.createSession(ctx, user.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err = service.db.DeleteSession(ctx, session.Id)
|
err = service.db.DeleteSession(ctx, session.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
|
tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, auth_types.TokenTypeCsrf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
err = service.db.DeleteToken(ctx, token.Token)
|
err = service.db.DeleteToken(ctx, token.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSession, user, nil
|
return newSession, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) {
|
func (service ServiceImpl) SignInSession(ctx context.Context, sessionId string) (*auth_types.Session, *auth_types.User, error) {
|
||||||
if sessionId == "" {
|
if sessionId == "" {
|
||||||
return nil, nil, ErrSessionIdInvalid
|
return nil, nil, ErrSessionIdInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := service.db.GetSession(ctx, sessionId)
|
session, err := service.db.GetSession(ctx, sessionId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
if session.ExpiresAt.Before(service.clock.Now()) {
|
if session.ExpiresAt.Before(service.clock.Now()) {
|
||||||
_ = service.db.DeleteSession(ctx, sessionId)
|
_ = service.db.DeleteSession(ctx, sessionId)
|
||||||
@@ -126,16 +126,16 @@ func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*t
|
|||||||
|
|
||||||
user, err := service.db.GetUser(ctx, session.UserId)
|
user, err := service.db.GetUser(ctx, session.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, types.ErrInternal
|
return nil, nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return session, user, nil
|
return session, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, error) {
|
func (service ServiceImpl) SignInAnonymous(ctx context.Context) (*auth_types.Session, error) {
|
||||||
session, err := service.createSession(ctx, uuid.Nil)
|
session, err := service.createSession(ctx, uuid.Nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
|
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
|
||||||
@@ -143,7 +143,7 @@ func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, er
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) {
|
func (service ServiceImpl) SignUp(ctx context.Context, email string, password string) (*auth_types.User, error) {
|
||||||
_, err := mail.ParseAddress(email)
|
_, err := mail.ParseAddress(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidEmail
|
return nil, ErrInvalidEmail
|
||||||
@@ -155,37 +155,37 @@ func (service AuthImpl) SignUp(ctx context.Context, email string, password strin
|
|||||||
|
|
||||||
userId, err := service.random.UUID(ctx)
|
userId, err := service.random.UUID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
salt, err := service.random.Bytes(ctx, 16)
|
salt, err := service.random.Bytes(ctx, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := GetHashPassword(password, salt)
|
hash := GetHashPassword(password, salt)
|
||||||
|
|
||||||
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
|
user := auth_types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
|
||||||
|
|
||||||
err = service.db.InsertUser(ctx, user)
|
err = service.db.InsertUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrAlreadyExists) {
|
if errors.Is(err, core.ErrAlreadyExists) {
|
||||||
return nil, ErrAccountExists
|
return nil, ErrAccountExists
|
||||||
} else {
|
} else {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) {
|
func (service ServiceImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) {
|
||||||
tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, types.TokenTypeEmailVerify)
|
tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, auth_types.TokenTypeEmailVerify)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var token *types.Token
|
var token *auth_types.Token
|
||||||
|
|
||||||
if len(tokens) > 0 {
|
if len(tokens) > 0 {
|
||||||
token = tokens[0]
|
token = tokens[0]
|
||||||
@@ -197,11 +197,11 @@ func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UU
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token = types.NewToken(
|
token = auth_types.NewToken(
|
||||||
userId,
|
userId,
|
||||||
"",
|
"",
|
||||||
newTokenStr,
|
newTokenStr,
|
||||||
types.TokenTypeEmailVerify,
|
auth_types.TokenTypeEmailVerify,
|
||||||
service.clock.Now(),
|
service.clock.Now(),
|
||||||
service.clock.Now().Add(24*time.Hour))
|
service.clock.Now().Add(24*time.Hour))
|
||||||
|
|
||||||
@@ -221,29 +221,29 @@ func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UU
|
|||||||
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
|
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
|
func (service ServiceImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
|
||||||
if tokenStr == "" {
|
if tokenStr == "" {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := service.db.GetToken(ctx, tokenStr)
|
token, err := service.db.GetToken(ctx, tokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := service.db.GetUser(ctx, token.UserId)
|
user, err := service.db.GetUser(ctx, token.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
if token.Type != types.TokenTypeEmailVerify {
|
if token.Type != auth_types.TokenTypeEmailVerify {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
now := service.clock.Now()
|
now := service.clock.Now()
|
||||||
|
|
||||||
if token.ExpiresAt.Before(now) {
|
if token.ExpiresAt.Before(now) {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
user.EmailVerified = true
|
user.EmailVerified = true
|
||||||
@@ -251,21 +251,21 @@ func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) er
|
|||||||
|
|
||||||
err = service.db.UpdateUser(ctx, user)
|
err = service.db.UpdateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = service.db.DeleteToken(ctx, token.Token)
|
_ = service.db.DeleteToken(ctx, token.Token)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error {
|
func (service ServiceImpl) SignOut(ctx context.Context, sessionId string) error {
|
||||||
return service.db.DeleteSession(ctx, sessionId)
|
return service.db.DeleteSession(ctx, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, currPass string) error {
|
func (service ServiceImpl) DeleteAccount(ctx context.Context, user *auth_types.User, currPass string) error {
|
||||||
userDb, err := service.db.GetUser(ctx, user.Id)
|
userDb, err := service.db.GetUser(ctx, user.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
currHash := GetHashPassword(currPass, userDb.Salt)
|
currHash := GetHashPassword(currPass, userDb.Salt)
|
||||||
@@ -283,7 +283,7 @@ func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, cur
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error {
|
func (service ServiceImpl) ChangePassword(ctx context.Context, user *auth_types.User, sessionId string, currPass, newPass string) error {
|
||||||
if !isPasswordValid(newPass) {
|
if !isPasswordValid(newPass) {
|
||||||
return ErrInvalidPassword
|
return ErrInvalidPassword
|
||||||
}
|
}
|
||||||
@@ -308,13 +308,13 @@ func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, se
|
|||||||
|
|
||||||
sessions, err := service.db.GetSessions(ctx, user.Id)
|
sessions, err := service.db.GetSessions(ctx, user.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
for _, s := range sessions {
|
for _, s := range sessions {
|
||||||
if s.Id != sessionId {
|
if s.Id != sessionId {
|
||||||
err = service.db.DeleteSession(ctx, s.Id)
|
err = service.db.DeleteSession(ctx, s.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,7 +322,7 @@ func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, se
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string) error {
|
func (service ServiceImpl) SendForgotPasswordMail(ctx context.Context, email string) error {
|
||||||
tokenStr, err := service.random.String(ctx, 32)
|
tokenStr, err := service.random.String(ctx, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -330,38 +330,38 @@ func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string
|
|||||||
|
|
||||||
user, err := service.db.GetUserByEmail(ctx, email)
|
user, err := service.db.GetUserByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
token := types.NewToken(
|
token := auth_types.NewToken(
|
||||||
user.Id,
|
user.Id,
|
||||||
"",
|
"",
|
||||||
tokenStr,
|
tokenStr,
|
||||||
types.TokenTypePasswordReset,
|
auth_types.TokenTypePasswordReset,
|
||||||
service.clock.Now(),
|
service.clock.Now(),
|
||||||
service.clock.Now().Add(15*time.Minute))
|
service.clock.Now().Add(15*time.Minute))
|
||||||
|
|
||||||
err = service.db.InsertToken(ctx, token)
|
err = service.db.InsertToken(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
var mail strings.Builder
|
var mail strings.Builder
|
||||||
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
|
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not render reset password email", "err", err)
|
slog.ErrorContext(ctx, "Could not render reset password email", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
service.mail.SendMail(ctx, email, "Reset Password", mail.String())
|
service.mail.SendMail(ctx, email, "Reset Password", mail.String())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
|
func (service ServiceImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
|
||||||
if !isPasswordValid(newPass) {
|
if !isPasswordValid(newPass) {
|
||||||
return ErrInvalidPassword
|
return ErrInvalidPassword
|
||||||
}
|
}
|
||||||
@@ -376,7 +376,7 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if token.Type != types.TokenTypePasswordReset ||
|
if token.Type != auth_types.TokenTypePasswordReset ||
|
||||||
token.ExpiresAt.Before(service.clock.Now()) {
|
token.ExpiresAt.Before(service.clock.Now()) {
|
||||||
return ErrTokenInvalid
|
return ErrTokenInvalid
|
||||||
}
|
}
|
||||||
@@ -384,7 +384,7 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
|
|||||||
user, err := service.db.GetUser(ctx, token.UserId)
|
user, err := service.db.GetUser(ctx, token.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not get user from token", "err", err)
|
slog.ErrorContext(ctx, "Could not get user from token", "err", err)
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
passHash := GetHashPassword(newPass, user.Salt)
|
passHash := GetHashPassword(newPass, user.Salt)
|
||||||
@@ -397,26 +397,26 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
|
|||||||
|
|
||||||
sessions, err := service.db.GetSessions(ctx, user.Id)
|
sessions, err := service.db.GetSessions(ctx, user.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, session := range sessions {
|
for _, session := range sessions {
|
||||||
err = service.db.DeleteSession(ctx, session.Id)
|
err = service.db.DeleteSession(ctx, session.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
|
func (service ServiceImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
|
||||||
token, err := service.db.GetToken(ctx, tokenStr)
|
token, err := service.db.GetToken(ctx, tokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if token.Type != types.TokenTypeCsrf ||
|
if token.Type != auth_types.TokenTypeCsrf ||
|
||||||
token.SessionId != sessionId ||
|
token.SessionId != sessionId ||
|
||||||
token.ExpiresAt.Before(service.clock.Now()) {
|
token.ExpiresAt.Before(service.clock.Now()) {
|
||||||
return false
|
return false
|
||||||
@@ -425,12 +425,12 @@ func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, s
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session) (string, error) {
|
func (service ServiceImpl) GetCsrfToken(ctx context.Context, session *auth_types.Session) (string, error) {
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return "", types.ErrInternal
|
return "", core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
|
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, auth_types.TokenTypeCsrf)
|
||||||
|
|
||||||
if len(tokens) > 0 {
|
if len(tokens) > 0 {
|
||||||
return tokens[0].Token, nil
|
return tokens[0].Token, nil
|
||||||
@@ -438,19 +438,19 @@ func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session
|
|||||||
|
|
||||||
tokenStr, err := service.random.String(ctx, 32)
|
tokenStr, err := service.random.String(ctx, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", types.ErrInternal
|
return "", core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
token := types.NewToken(
|
token := auth_types.NewToken(
|
||||||
session.UserId,
|
session.UserId,
|
||||||
session.Id,
|
session.Id,
|
||||||
tokenStr,
|
tokenStr,
|
||||||
types.TokenTypeCsrf,
|
auth_types.TokenTypeCsrf,
|
||||||
service.clock.Now(),
|
service.clock.Now(),
|
||||||
service.clock.Now().Add(8*time.Hour))
|
service.clock.Now().Add(8*time.Hour))
|
||||||
err = service.db.InsertToken(ctx, token)
|
err = service.db.InsertToken(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", types.ErrInternal
|
return "", core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr)
|
slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr)
|
||||||
@@ -458,34 +458,34 @@ func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session
|
|||||||
return tokenStr, nil
|
return tokenStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) CleanupSessionsAndTokens(ctx context.Context) error {
|
func (service ServiceImpl) CleanupSessionsAndTokens(ctx context.Context) error {
|
||||||
err := service.db.DeleteOldSessions(ctx)
|
err := service.db.DeleteOldSessions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err = service.db.DeleteOldTokens(ctx)
|
err = service.db.DeleteOldTokens(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) createSession(ctx context.Context, userId uuid.UUID) (*types.Session, error) {
|
func (service ServiceImpl) createSession(ctx context.Context, userId uuid.UUID) (*auth_types.Session, error) {
|
||||||
sessionId, err := service.random.String(ctx, 32)
|
sessionId, err := service.random.String(ctx, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
createAt := service.clock.Now()
|
createAt := service.clock.Now()
|
||||||
expiresAt := createAt.Add(24 * time.Hour)
|
expiresAt := createAt.Add(24 * time.Hour)
|
||||||
|
|
||||||
session := types.NewSession(sessionId, userId, createAt, expiresAt)
|
session := auth_types.NewSession(sessionId, userId, createAt, expiresAt)
|
||||||
|
|
||||||
err = service.db.InsertSession(ctx, session)
|
err = service.db.InsertSession(ctx, session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return session, nil
|
return session, nil
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package auth
|
package template
|
||||||
|
|
||||||
templ ChangePasswordComp(isPasswordReset bool) {
|
templ ChangePasswordComp(isPasswordReset bool) {
|
||||||
<form
|
<form
|
||||||
1
internal/authentication/template/default.go
Normal file
1
internal/authentication/template/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package template
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package auth
|
package template
|
||||||
|
|
||||||
templ DeleteAccountComp() {
|
templ DeleteAccountComp() {
|
||||||
<form
|
<form
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package auth
|
package template
|
||||||
|
|
||||||
templ ResetPasswordComp() {
|
templ ResetPasswordComp() {
|
||||||
<form
|
<form
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package auth
|
package template
|
||||||
|
|
||||||
templ SignInOrUpComp(isSignIn bool) {
|
templ SignInOrUpComp(isSignIn bool) {
|
||||||
{{
|
{{
|
||||||
var postUrl string
|
var postUrl string
|
||||||
if isSignIn {
|
if isSignIn {
|
||||||
postUrl = "/api/auth/signin"
|
postUrl = "/api/auth/signin"
|
||||||
} else {
|
} else {
|
||||||
postUrl = "/api/auth/signup"
|
postUrl = "/api/auth/signup"
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<form
|
<form
|
||||||
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package auth
|
package template
|
||||||
|
|
||||||
templ VerifyComp() {
|
templ VerifyComp() {
|
||||||
<main class="h-full">
|
<main class="h-full">
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package auth
|
package template
|
||||||
|
|
||||||
templ VerifyResponseComp(isVerified bool) {
|
templ VerifyResponseComp(isVerified bool) {
|
||||||
<main>
|
<main>
|
||||||
97
internal/budget/db.go
Normal file
97
internal/budget/db.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package budget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Db interface {
|
||||||
|
Insert(ctx context.Context, budget Budget) (*Budget, error)
|
||||||
|
Update(ctx context.Context, budget Budget) (*Budget, error)
|
||||||
|
Delete(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) error
|
||||||
|
Get(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) (*Budget, error)
|
||||||
|
GetAll(ctx context.Context, userId uuid.UUID) ([]Budget, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DbSqlite struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDbSqlite(db *sqlx.DB) *DbSqlite {
|
||||||
|
return &DbSqlite{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) Insert(ctx context.Context, budget Budget) (*Budget, error) {
|
||||||
|
r, err := db.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO budget (id, user_id, name, value, created_at, created_by, updated_at, updated_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
budget.Id, budget.UserId, budget.Name, budget.Value, budget.CreatedAt, budget.CreatedBy, budget.UpdatedAt, budget.UpdatedBy,
|
||||||
|
)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "budget", r, err)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Get(ctx, budget.UserId, budget.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) Update(ctx context.Context, budget Budget) (*Budget, error) {
|
||||||
|
_, err := db.db.ExecContext(ctx, `
|
||||||
|
UPDATE budget
|
||||||
|
SET name = ?,
|
||||||
|
value = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
updated_by = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND id = ?`,
|
||||||
|
budget.Name, budget.Value, budget.UpdatedAt, budget.UpdatedBy, budget.UserId, budget.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Get(ctx, budget.UserId, budget.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) Delete(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) error {
|
||||||
|
r, err := db.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"DELETE FROM budget WHERE user_id = ? AND id = ?",
|
||||||
|
userId,
|
||||||
|
budgetId)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "budget", r, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) Get(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) (*Budget, error) {
|
||||||
|
var budget Budget
|
||||||
|
err := db.db.Get(&budget, "SELECT * FROM budget WHERE id = ? AND user_id = ?", budgetId, userId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Could not get budget", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return &budget, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) GetAll(ctx context.Context, userId uuid.UUID) ([]Budget, error) {
|
||||||
|
var budgets []Budget
|
||||||
|
err := db.db.Select(&budgets, "SELECT * FROM budget WHERE user_id = ?", userId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Could not GetAll budget", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return budgets, nil
|
||||||
|
}
|
||||||
184
internal/budget/handler.go
Normal file
184
internal/budget/handler.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package budget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DECIMALS_MULTIPLIER = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerImpl struct {
|
||||||
|
s Service
|
||||||
|
r *core.Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(s Service, r *core.Render) Handler {
|
||||||
|
return HandlerImpl{
|
||||||
|
s: s,
|
||||||
|
r: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||||
|
r.Handle("GET /budget", h.handlePage())
|
||||||
|
r.Handle("GET /budget/new", h.handleNew())
|
||||||
|
r.Handle("GET /budget/{id}", h.handleEdit())
|
||||||
|
r.Handle("POST /budget/{id}", h.handlePost())
|
||||||
|
r.Handle("DELETE /budget/{id}", h.handleDelete())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handlePage() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
budgets, err := h.s.GetAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
h.r.RenderLayout(r, w, core.ErrorComp(err), user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := page(budgets)
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handleNew() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := editNew()
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handleEdit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
budget, err := h.s.Get(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := edit(*budget)
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handlePost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr != "new" {
|
||||||
|
id, err = uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value := int64(math.Round(valueF * DECIMALS_MULTIPLIER))
|
||||||
|
|
||||||
|
input := Budget{
|
||||||
|
Id: id,
|
||||||
|
Name: r.FormValue("name"),
|
||||||
|
Value: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if idStr == "new" {
|
||||||
|
_, err = h.s.Add(r.Context(), user, input)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = h.s.Update(r.Context(), user, input)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core.DoRedirect(w, r, "/budget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handleDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.s.Delete(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
core.DoRedirect(w, r, "/budget")
|
||||||
|
}
|
||||||
|
}
|
||||||
119
internal/budget/service.go
Normal file
119
internal/budget/service.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package budget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"spend-sparrow/internal/auth_types"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
Add(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error)
|
||||||
|
Update(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error)
|
||||||
|
Delete(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) error
|
||||||
|
Get(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) (*Budget, error)
|
||||||
|
GetAll(ctx context.Context, user *auth_types.User) ([]Budget, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceImpl struct {
|
||||||
|
db Db
|
||||||
|
clock core.Clock
|
||||||
|
random core.Random
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db Db, random core.Random, clock core.Clock) Service {
|
||||||
|
return ServiceImpl{
|
||||||
|
db: db,
|
||||||
|
clock: clock,
|
||||||
|
random: random,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid := s.isBudgetValid(budget)
|
||||||
|
if !isValid {
|
||||||
|
return nil, core.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
newId, err := s.random.UUID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.Id = newId
|
||||||
|
budget.UserId = user.Id
|
||||||
|
budget.CreatedBy = user.Id
|
||||||
|
budget.CreatedAt = s.clock.Now()
|
||||||
|
|
||||||
|
return s.db.Insert(ctx, budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Budget) (*Budget, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
budget, err := s.Get(ctx, user, input.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.Name = input.Name
|
||||||
|
budget.Value = input.Value
|
||||||
|
|
||||||
|
if user.Id != budget.UserId {
|
||||||
|
return nil, core.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid := s.isBudgetValid(*budget)
|
||||||
|
if !isValid {
|
||||||
|
return nil, core.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.UpdatedBy = &user.Id
|
||||||
|
now := s.clock.Now()
|
||||||
|
budget.UpdatedAt = &now
|
||||||
|
|
||||||
|
return s.db.Update(ctx, *budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) error {
|
||||||
|
if user == nil {
|
||||||
|
return core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Delete(ctx, user.Id, budgetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) (*Budget, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Get(ctx, user.Id, budgetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]Budget, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.GetAll(ctx, user.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) isBudgetValid(budget Budget) bool {
|
||||||
|
err := core.ValidateString(budget.Name, "name")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if budget.Value < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
138
internal/budget/template.templ
Normal file
138
internal/budget/template.templ
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package budget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/tag"
|
||||||
|
"spend-sparrow/internal/template/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ page(budgets []Budget) {
|
||||||
|
@core.Breadcrumb([]string{"Home", "Budget"}, []string{"/", "/budget"})
|
||||||
|
<div class="flex flex-wrap gap-20 text-xl mt-10 justify-center">
|
||||||
|
@newItem()
|
||||||
|
for _,budget:=range(budgets ) {
|
||||||
|
@item(budget)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ editNew() {
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
@core.Breadcrumb([]string{"Home", "Budget", "New"}, []string{"/", "/budget", "/budget/new"})
|
||||||
|
<div class="flex justify-center items-center flex-1">
|
||||||
|
<form
|
||||||
|
hx-post={ "/budget/new" }
|
||||||
|
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
|
||||||
|
>
|
||||||
|
<label for="timestamp" class="text-sm text-gray-500">Name</label>
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
class="bg-white input datetime col-span-3"
|
||||||
|
/>
|
||||||
|
<label for="value" class="text-sm text-gray-500">Value</label>
|
||||||
|
<input
|
||||||
|
name="value"
|
||||||
|
type="number"
|
||||||
|
class="bg-white input col-span-3"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-6 justify-end col-span-4">
|
||||||
|
<a href="/budget" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
<span class="h-4 w-4">
|
||||||
|
@svg.Cancel()
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
@svg.Save()
|
||||||
|
<span>
|
||||||
|
Save
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ edit(budget Budget) {
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
@core.Breadcrumb([]string{"Home", "Budget", budget.Name}, []string{"/", "/budget", "/budget/" + budget.Id.String()})
|
||||||
|
<div class="flex justify-center items-center flex-1">
|
||||||
|
<form
|
||||||
|
hx-post={ "/budget/" + budget.Id.String() }
|
||||||
|
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
|
||||||
|
>
|
||||||
|
<label for="timestamp" class="text-sm text-gray-500">Name</label>
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={ budget.Name }
|
||||||
|
class="bg-white input datetime col-span-3"
|
||||||
|
/>
|
||||||
|
<label for="value" class="text-sm text-gray-500">Value</label>
|
||||||
|
<input
|
||||||
|
name="value"
|
||||||
|
type="number"
|
||||||
|
value={ budget.Value / 100 }
|
||||||
|
class="bg-white input col-span-3"
|
||||||
|
/>
|
||||||
|
<label for="tag" class="text-sm text-gray-500">Tags</label>
|
||||||
|
@tag.InlineEditInput("col-span-3")
|
||||||
|
<div class="flex flex-row-reverse gap-6 justify-end col-span-4">
|
||||||
|
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
@svg.Save()
|
||||||
|
<span>
|
||||||
|
Save
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<a href="/budget" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
<span class="h-4 w-4">
|
||||||
|
@svg.Cancel()
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
hx-delete={ "/budget/" + budget.Id.String() }
|
||||||
|
hx-confirm={ "Do you really want to delete '" + budget.Name + "'" }
|
||||||
|
class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-red-50 rounded-lg hover:underline flex items-center gap-2 justify-center"
|
||||||
|
>
|
||||||
|
@svg.Delete()
|
||||||
|
<span>
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ newItem() {
|
||||||
|
<a
|
||||||
|
href="/budget/new"
|
||||||
|
class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300"
|
||||||
|
>
|
||||||
|
New Budget
|
||||||
|
<div class="w-10">
|
||||||
|
@svg.Plus()
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ item(budget Budget) {
|
||||||
|
<a href={ "/budget/" + budget.Id.String() } class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300">
|
||||||
|
<span>
|
||||||
|
{ budget.Name }
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{ core.FormatEuros(budget.Value) }
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
20
internal/budget/types.go
Normal file
20
internal/budget/types.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package budget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Budget struct {
|
||||||
|
Id uuid.UUID `db:"id"`
|
||||||
|
UserId uuid.UUID `db:"user_id"`
|
||||||
|
|
||||||
|
Name string `db:"name"`
|
||||||
|
Value int64 `db:"value"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
CreatedBy uuid.UUID `db:"created_by"`
|
||||||
|
UpdatedAt *time.Time `db:"updated_at"`
|
||||||
|
UpdatedBy *uuid.UUID `db:"updated_by"`
|
||||||
|
}
|
||||||
51
internal/core/auth.go
Normal file
51
internal/core/auth.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/internal/auth_types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContextKey string
|
||||||
|
|
||||||
|
var SessionKey ContextKey = "session"
|
||||||
|
var UserKey ContextKey = "user"
|
||||||
|
|
||||||
|
func GetUser(r *http.Request) *auth_types.User {
|
||||||
|
obj := r.Context().Value(UserKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok := obj.(*auth_types.User)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSession(r *http.Request) *auth_types.Session {
|
||||||
|
obj := r.Context().Value(SessionKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session, ok := obj.(*auth_types.Session)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSessionCookie(sessionId string) http.Cookie {
|
||||||
|
return http.Cookie{
|
||||||
|
Name: "id",
|
||||||
|
Value: sessionId,
|
||||||
|
MaxAge: 60 * 60 * 8, // 8 hours
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/core/breadcrumb.templ
Normal file
13
internal/core/breadcrumb.templ
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
templ Breadcrumb(elements []string, links []string) {
|
||||||
|
<div class="flex gap-5 mb-10 text-lg items-center">
|
||||||
|
for i, element := range(elements) {
|
||||||
|
if (i>0) {
|
||||||
|
<span class="text-gray-500">></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>
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package core
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
70
internal/core/error.go
Normal file
70
internal/core/error.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("the value does not exist")
|
||||||
|
ErrAlreadyExists = errors.New("row already exists")
|
||||||
|
|
||||||
|
ErrInternal = errors.New("internal server error")
|
||||||
|
ErrUnauthorized = errors.New("you are not authorized to perform this action")
|
||||||
|
|
||||||
|
ErrBadRequest = errors.New("bad request")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
|
||||||
|
return ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if r != nil {
|
||||||
|
rows, err := r.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
|
||||||
|
return ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
slog.InfoContext(ctx, "row not found", "module", module)
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUnauthorized):
|
||||||
|
TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
case errors.Is(err, ErrBadRequest):
|
||||||
|
TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractErrorMessage(err error) string {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if errMsg == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.SplitN(errMsg, ":", 2)[0]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package utils
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package template
|
package core
|
||||||
|
|
||||||
import "spend-sparrow/internal/template/svg"
|
import (
|
||||||
|
"spend-sparrow/internal/template/svg"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
func layoutLinkClass(isActive bool) string {
|
func layoutLinkClass(isActive bool) string {
|
||||||
common := "text-2xl p-2 text-gray-900 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg"
|
common := "text-2xl p-2 text-gray-900 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg"
|
||||||
@@ -87,9 +90,17 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
|
|||||||
|
|
||||||
templ navigation(path string) {
|
templ navigation(path string) {
|
||||||
<nav class="w-64 text-nowrap flex gap-2 flex-col text-lg mt-5 px-5 pt-2">
|
<nav class="w-64 text-nowrap flex gap-2 flex-col text-lg mt-5 px-5 pt-2">
|
||||||
<a class={ layoutLinkClass(path == "/dashboard") } href="/dashboard">Dashboard</a>
|
<a class={ layoutLinkClass(strings.HasPrefix(path, "/dashboard")) } href="/dashboard">Dashboard</a>
|
||||||
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
|
<a class={ layoutLinkClass(strings.HasPrefix(path, "/transaction")) } href="/transaction">Transaction</a>
|
||||||
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
|
<a class={ layoutLinkClass(strings.HasPrefix(path, "/treasurechest")) } href="/treasurechest">Treasure Chest</a>
|
||||||
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
|
<a class={ layoutLinkClass(strings.HasPrefix(path, "/account")) } href="/account">Account</a>
|
||||||
|
<a class={ layoutLinkClass(strings.HasPrefix(path, "/budget")) } href="/budget">Budget</a>
|
||||||
|
<a class={ layoutLinkClass(strings.HasPrefix(path, "/tag")) } href="/tag">Tag</a>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ ErrorComp(err error) {
|
||||||
|
<div>
|
||||||
|
The following error occured: { err.Error() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package log
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package service
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Mail interface {
|
type Mail interface {
|
||||||
@@ -14,10 +13,10 @@ type Mail interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MailImpl struct {
|
type MailImpl struct {
|
||||||
server *types.Settings
|
server *Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMail(server *types.Settings) MailImpl {
|
func NewMail(server *Settings) MailImpl {
|
||||||
return MailImpl{server: server}
|
return MailImpl{server: server}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package db
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
@@ -25,7 +24,7 @@ func RunMigrations(ctx context.Context, db *sqlx.DB, pathPrefix string) error {
|
|||||||
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
|
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not create Migration instance", "err", err)
|
slog.ErrorContext(ctx, "Could not create Migration instance", "err", err)
|
||||||
return types.ErrInternal
|
return ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := migrate.NewWithDatabaseInstance(
|
m, err := migrate.NewWithDatabaseInstance(
|
||||||
@@ -34,14 +33,14 @@ func RunMigrations(ctx context.Context, db *sqlx.DB, pathPrefix string) error {
|
|||||||
driver)
|
driver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not create migrations instance", "err", err)
|
slog.ErrorContext(ctx, "Could not create migrations instance", "err", err)
|
||||||
return types.ErrInternal
|
return ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Log = migrationLogger{}
|
m.Log = migrationLogger{}
|
||||||
|
|
||||||
if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
slog.ErrorContext(ctx, "Could not run migrations", "err", err)
|
slog.ErrorContext(ctx, "Could not run migrations", "err", err)
|
||||||
return types.ErrInternal
|
return ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
16
internal/core/observabillity.go
Normal file
16
internal/core/observabillity.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateSpan(r *http.Request) {
|
||||||
|
currentSpan := trace.SpanFromContext(r.Context())
|
||||||
|
if currentSpan != nil {
|
||||||
|
currentSpan.SetAttributes(attribute.String("http.pattern", r.Pattern))
|
||||||
|
currentSpan.SetAttributes(attribute.String("http.pattern.id", r.PathValue("id")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package service
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -28,7 +27,7 @@ func (r *RandomImpl) Bytes(ctx context.Context, tsize int) ([]byte, error) {
|
|||||||
_, err := rand.Read(b)
|
_, err := rand.Read(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Error generating random bytes", "err", err)
|
slog.ErrorContext(ctx, "Error generating random bytes", "err", err)
|
||||||
return []byte{}, types.ErrInternal
|
return []byte{}, ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return b, nil
|
return b, nil
|
||||||
@@ -38,7 +37,7 @@ func (r *RandomImpl) String(ctx context.Context, size int) (string, error) {
|
|||||||
bytes, err := r.Bytes(ctx, size)
|
bytes, err := r.Bytes(ctx, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Error generating random string", "err", err)
|
slog.ErrorContext(ctx, "Error generating random string", "err", err)
|
||||||
return "", types.ErrInternal
|
return "", ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return base64.StdEncoding.EncodeToString(bytes), nil
|
return base64.StdEncoding.EncodeToString(bytes), nil
|
||||||
@@ -48,7 +47,7 @@ func (r *RandomImpl) UUID(ctx context.Context) (uuid.UUID, error) {
|
|||||||
id, err := uuid.NewRandom()
|
id, err := uuid.NewRandom()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Error generating random UUID", "err", err)
|
slog.ErrorContext(ctx, "Error generating random UUID", "err", err)
|
||||||
return uuid.Nil, types.ErrInternal
|
return uuid.Nil, ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package handler
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/template"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/template/auth"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
@@ -31,21 +29,21 @@ func (render *Render) Render(r *http.Request, w http.ResponseWriter, comp templ.
|
|||||||
render.RenderWithStatus(r, w, comp, http.StatusOK)
|
render.RenderWithStatus(r, w, comp, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User) {
|
func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *auth_types.User) {
|
||||||
render.RenderLayoutWithStatus(r, w, slot, user, http.StatusOK)
|
render.RenderLayoutWithStatus(r, w, slot, user, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
|
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *auth_types.User, status int) {
|
||||||
userComp := render.getUserComp(user)
|
userComp := render.getUserComp(user)
|
||||||
layout := template.Layout(slot, userComp, user != nil, r.URL.Path)
|
layout := Layout(slot, userComp, user != nil, r.URL.Path)
|
||||||
|
|
||||||
render.RenderWithStatus(r, w, layout, status)
|
render.RenderWithStatus(r, w, layout, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (render *Render) getUserComp(user *types.User) templ.Component {
|
func (render *Render) getUserComp(user *auth_types.User) templ.Component {
|
||||||
if user != nil {
|
if user != nil {
|
||||||
return auth.UserComp(user.Email)
|
return UserComp(user.Email)
|
||||||
} else {
|
} else {
|
||||||
return auth.UserComp("")
|
return UserComp("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package auth
|
package core
|
||||||
|
|
||||||
templ UserComp(user string) {
|
templ UserComp(user string) {
|
||||||
<div id="user-info" class="flex items-center gap-2 text-nowrap">
|
<div id="user-info" class="flex items-center gap-2 text-nowrap">
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
package service
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
DECIMALS_MULTIPLIER = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`)
|
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?\(\)]+$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateString(value string, fieldName string) error {
|
func ValidateString(value string, fieldName string) error {
|
||||||
switch {
|
switch {
|
||||||
case value == "":
|
case value == "":
|
||||||
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)
|
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)
|
||||||
@@ -1,87 +1,89 @@
|
|||||||
package handler
|
package dashboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/handler/middleware"
|
"spend-sparrow/internal/core"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/treasure_chest"
|
||||||
"spend-sparrow/internal/template/dashboard"
|
"strings"
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Dashboard interface {
|
type Handler interface {
|
||||||
Handle(router *http.ServeMux)
|
Handle(router *http.ServeMux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardImpl struct {
|
type HandlerImpl struct {
|
||||||
r *Render
|
r *core.Render
|
||||||
d *service.Dashboard
|
s *Service
|
||||||
treasureChest service.TreasureChest
|
treasureChest treasure_chest.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboard(r *Render, d *service.Dashboard, treasureChest service.TreasureChest) Dashboard {
|
func NewHandler(r *core.Render, s *Service, treasureChest treasure_chest.Service) Handler {
|
||||||
return DashboardImpl{
|
return HandlerImpl{
|
||||||
r: r,
|
r: r,
|
||||||
d: d,
|
s: s,
|
||||||
treasureChest: treasureChest,
|
treasureChest: treasureChest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler DashboardImpl) Handle(router *http.ServeMux) {
|
func (handler HandlerImpl) Handle(router *http.ServeMux) {
|
||||||
router.Handle("GET /dashboard", handler.handleDashboard())
|
router.Handle("GET /dashboard", handler.handleDashboard())
|
||||||
router.Handle("GET /dashboard/main-chart", handler.handleDashboardMainChart())
|
router.Handle("GET /dashboard/main-chart", handler.handleMainChart())
|
||||||
router.Handle("GET /dashboard/treasure-chests", handler.handleDashboardTreasureChests())
|
router.Handle("GET /dashboard/treasure-chests", handler.handleTreasureChests())
|
||||||
router.Handle("GET /dashboard/treasure-chest", handler.handleDashboardTreasureChest())
|
router.Handle("GET /dashboard/treasure-chest", handler.handleTreasureChest())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler DashboardImpl) handleDashboard() http.HandlerFunc {
|
func (handler HandlerImpl) handleDashboard() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests, err := handler.treasureChest.GetAll(r.Context(), user)
|
treasureChests, err := handler.treasureChest.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := dashboard.Dashboard(treasureChests)
|
comp := DashboardComp(treasureChests)
|
||||||
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
|
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc {
|
func (handler HandlerImpl) handleMainChart() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
|
|
||||||
series, err := handler.d.MainChart(r.Context(), user)
|
series, err := handler.s.MainChart(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
account := ""
|
accountBuilder := strings.Builder{}
|
||||||
savings := ""
|
savingsBuilder := strings.Builder{}
|
||||||
|
|
||||||
for _, entry := range series {
|
for _, entry := range series {
|
||||||
account += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
|
fmt.Fprintf(&accountBuilder, `["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
|
||||||
savings += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100)
|
fmt.Fprintf(&savingsBuilder, `["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
account := accountBuilder.String()
|
||||||
|
savings := savingsBuilder.String()
|
||||||
|
|
||||||
account = account[:len(account)-1]
|
account = account[:len(account)-1]
|
||||||
savings = savings[:len(savings)-1]
|
savings = savings[:len(savings)-1]
|
||||||
|
|
||||||
@@ -122,38 +124,39 @@ func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
|
func (handler HandlerImpl) handleTreasureChests() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
|
|
||||||
treeList, err := handler.d.TreasureChests(r.Context(), user)
|
treeList, err := handler.s.TreasureChests(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
data := ""
|
dataBuilder := strings.Builder{}
|
||||||
|
|
||||||
for _, item := range treeList {
|
for _, item := range treeList {
|
||||||
children := ""
|
childrenBuilder := strings.Builder{}
|
||||||
|
|
||||||
for _, child := range item.Children {
|
for _, child := range item.Children {
|
||||||
if child.Value < 0 {
|
if child.Value < 0 {
|
||||||
children += fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value)
|
fmt.Fprintf(&childrenBuilder, `{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value)
|
||||||
} else {
|
} else {
|
||||||
children += fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value)
|
fmt.Fprintf(&childrenBuilder, `{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
children := childrenBuilder.String()
|
||||||
children = children[:len(children)-1]
|
children = children[:len(children)-1]
|
||||||
data += fmt.Sprintf(`{"name":"%s","children":[%s]},`, item.Name, children)
|
fmt.Fprintf(&dataBuilder, `{"name":"%s","children":[%s]},`, item.Name, children)
|
||||||
}
|
}
|
||||||
|
data := dataBuilder.String()
|
||||||
data = data[:len(data)-1]
|
data = data[:len(data)-1]
|
||||||
|
|
||||||
_, err = fmt.Fprintf(w, `
|
_, err = fmt.Fprintf(w, `
|
||||||
@@ -176,11 +179,11 @@ func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
|
func (handler HandlerImpl) handleTreasureChest() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
|
|
||||||
var treasureChestId *uuid.UUID
|
var treasureChestId *uuid.UUID
|
||||||
|
|
||||||
@@ -188,28 +191,30 @@ func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
|
|||||||
if treasureChestStr != "" {
|
if treasureChestStr != "" {
|
||||||
id, err := uuid.Parse(treasureChestStr)
|
id, err := uuid.Parse(treasureChestStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, fmt.Errorf("could not parse treasure chest: %w", service.ErrBadRequest))
|
core.HandleError(w, r, fmt.Errorf("could not parse treasure chest: %w", core.ErrBadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChestId = &id
|
treasureChestId = &id
|
||||||
}
|
}
|
||||||
|
|
||||||
series, err := handler.d.TreasureChest(r.Context(), user, treasureChestId)
|
series, err := handler.s.TreasureChest(r.Context(), user, treasureChestId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
value := ""
|
valueBuilder := strings.Builder{}
|
||||||
|
|
||||||
for _, entry := range series {
|
for _, entry := range series {
|
||||||
value += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
|
fmt.Fprintf(&valueBuilder, `["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
value := valueBuilder.String()
|
||||||
|
|
||||||
if len(value) > 0 {
|
if len(value) > 0 {
|
||||||
value = value[:len(value)-1]
|
value = value[:len(value)-1]
|
||||||
}
|
}
|
||||||
@@ -1,47 +1,50 @@
|
|||||||
package service
|
package dashboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/transaction"
|
||||||
|
"spend-sparrow/internal/treasure_chest"
|
||||||
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Dashboard struct {
|
type Service struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboard(db *sqlx.DB) *Dashboard {
|
func NewService(db *sqlx.DB) *Service {
|
||||||
return &Dashboard{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Dashboard) MainChart(
|
func (s Service) MainChart(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
user *types.User,
|
user *auth_types.User,
|
||||||
) ([]types.DashboardMainChartEntry, error) {
|
) ([]DashboardMainChartEntry, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions := make([]types.Transaction, 0)
|
transactions := make([]transaction.Transaction, 0)
|
||||||
err := s.db.SelectContext(ctx, &transactions, `
|
err := s.db.SelectContext(ctx, &transactions, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM "transaction"
|
FROM "transaction"
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY timestamp`, user.Id)
|
ORDER BY timestamp`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
err = core.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
timeEntries := make([]types.DashboardMainChartEntry, 0)
|
timeEntries := make([]DashboardMainChartEntry, 0)
|
||||||
|
|
||||||
var lastEntry *types.DashboardMainChartEntry
|
var lastEntry *DashboardMainChartEntry
|
||||||
|
|
||||||
for _, t := range transactions {
|
for _, t := range transactions {
|
||||||
if t.Error != nil {
|
if t.Error != nil {
|
||||||
@@ -50,14 +53,14 @@ func (s Dashboard) MainChart(
|
|||||||
|
|
||||||
newDay := t.Timestamp.Truncate(24 * time.Hour)
|
newDay := t.Timestamp.Truncate(24 * time.Hour)
|
||||||
if lastEntry == nil {
|
if lastEntry == nil {
|
||||||
lastEntry = &types.DashboardMainChartEntry{
|
lastEntry = &DashboardMainChartEntry{
|
||||||
Day: newDay,
|
Day: newDay,
|
||||||
Value: 0,
|
Value: 0,
|
||||||
Savings: 0,
|
Savings: 0,
|
||||||
}
|
}
|
||||||
} else if lastEntry.Day != newDay {
|
} else if lastEntry.Day != newDay {
|
||||||
timeEntries = append(timeEntries, *lastEntry)
|
timeEntries = append(timeEntries, *lastEntry)
|
||||||
lastEntry = &types.DashboardMainChartEntry{
|
lastEntry = &DashboardMainChartEntry{
|
||||||
Day: newDay,
|
Day: newDay,
|
||||||
Value: lastEntry.Value,
|
Value: lastEntry.Value,
|
||||||
Savings: lastEntry.Savings,
|
Savings: lastEntry.Savings,
|
||||||
@@ -80,37 +83,37 @@ func (s Dashboard) MainChart(
|
|||||||
return timeEntries, nil
|
return timeEntries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Dashboard) TreasureChests(
|
func (s Service) TreasureChests(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
user *types.User,
|
user *auth_types.User,
|
||||||
) ([]*types.DashboardTreasureChest, error) {
|
) ([]*DashboardTreasureChest, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests := make([]*types.TreasureChest, 0)
|
treasureChests := make([]*treasure_chest_types.TreasureChest, 0)
|
||||||
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
|
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "dashboard TreasureChests", nil, err)
|
err = core.TransformAndLogDbError(ctx, "dashboard TreasureChests", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests = sortTreasureChests(treasureChests)
|
treasureChests = treasure_chest.SortTreasureChests(treasureChests)
|
||||||
|
|
||||||
result := make([]*types.DashboardTreasureChest, 0)
|
result := make([]*DashboardTreasureChest, 0)
|
||||||
|
|
||||||
for _, t := range treasureChests {
|
for _, t := range treasureChests {
|
||||||
if t.ParentId == nil {
|
if t.ParentId == nil {
|
||||||
result = append(result, &types.DashboardTreasureChest{
|
result = append(result, &DashboardTreasureChest{
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Value: t.CurrentBalance,
|
Value: t.CurrentBalance,
|
||||||
Children: make([]types.DashboardTreasureChest, 0),
|
Children: make([]DashboardTreasureChest, 0),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
result[len(result)-1].Children = append(result[len(result)-1].Children, types.DashboardTreasureChest{
|
result[len(result)-1].Children = append(result[len(result)-1].Children, DashboardTreasureChest{
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Value: t.CurrentBalance,
|
Value: t.CurrentBalance,
|
||||||
Children: make([]types.DashboardTreasureChest, 0),
|
Children: make([]DashboardTreasureChest, 0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,30 +121,30 @@ func (s Dashboard) TreasureChests(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Dashboard) TreasureChest(
|
func (s Service) TreasureChest(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
user *types.User,
|
user *auth_types.User,
|
||||||
treausureChestId *uuid.UUID,
|
treausureChestId *uuid.UUID,
|
||||||
) ([]types.DashboardMainChartEntry, error) {
|
) ([]DashboardMainChartEntry, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions := make([]types.Transaction, 0)
|
transactions := make([]transaction.Transaction, 0)
|
||||||
err := s.db.SelectContext(ctx, &transactions, `
|
err := s.db.SelectContext(ctx, &transactions, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM "transaction"
|
FROM "transaction"
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND treasure_chest_id = ?
|
AND treasure_chest_id = ?
|
||||||
ORDER BY timestamp`, user.Id, treausureChestId)
|
ORDER BY timestamp`, user.Id, treausureChestId)
|
||||||
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
err = core.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
timeEntries := make([]types.DashboardMainChartEntry, 0)
|
timeEntries := make([]DashboardMainChartEntry, 0)
|
||||||
|
|
||||||
var lastEntry *types.DashboardMainChartEntry
|
var lastEntry *DashboardMainChartEntry
|
||||||
|
|
||||||
for _, t := range transactions {
|
for _, t := range transactions {
|
||||||
if t.Error != nil {
|
if t.Error != nil {
|
||||||
@@ -150,13 +153,13 @@ func (s Dashboard) TreasureChest(
|
|||||||
|
|
||||||
newDay := t.Timestamp.Truncate(24 * time.Hour)
|
newDay := t.Timestamp.Truncate(24 * time.Hour)
|
||||||
if lastEntry == nil {
|
if lastEntry == nil {
|
||||||
lastEntry = &types.DashboardMainChartEntry{
|
lastEntry = &DashboardMainChartEntry{
|
||||||
Day: newDay,
|
Day: newDay,
|
||||||
Value: 0,
|
Value: 0,
|
||||||
}
|
}
|
||||||
} else if lastEntry.Day != newDay {
|
} else if lastEntry.Day != newDay {
|
||||||
timeEntries = append(timeEntries, *lastEntry)
|
timeEntries = append(timeEntries, *lastEntry)
|
||||||
lastEntry = &types.DashboardMainChartEntry{
|
lastEntry = &DashboardMainChartEntry{
|
||||||
Day: newDay,
|
Day: newDay,
|
||||||
Value: lastEntry.Value,
|
Value: lastEntry.Value,
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package dashboard
|
package dashboard
|
||||||
|
|
||||||
import "spend-sparrow/internal/types"
|
import "spend-sparrow/internal/treasure_chest_types"
|
||||||
|
|
||||||
templ Dashboard(treasureChests []*types.TreasureChest) {
|
templ DashboardComp(treasureChests []*treasure_chest_types.TreasureChest) {
|
||||||
<div class="mt-10 h-full">
|
<div class="mt-10 h-full">
|
||||||
<div id="main-chart" class="h-96 mt-10"></div>
|
<div id="main-chart" class="h-96 mt-10"></div>
|
||||||
<div id="treasure-chests" class="h-96 mt-10"></div>
|
<div id="treasure-chests" class="h-96 mt-10"></div>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package dashboard
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNotFound = errors.New("the value does not exist")
|
|
||||||
ErrAlreadyExists = errors.New("row already exists")
|
|
||||||
)
|
|
||||||
|
|
||||||
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
|
|
||||||
return types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
if r != nil {
|
|
||||||
rows, err := r.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
|
|
||||||
return types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
slog.InfoContext(ctx, "row not found", "module", module)
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"spend-sparrow/internal/db"
|
|
||||||
"spend-sparrow/internal/handler"
|
|
||||||
"spend-sparrow/internal/handler/middleware"
|
|
||||||
"spend-sparrow/internal/log"
|
|
||||||
"spend-sparrow/internal/service"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
|
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"spend-sparrow/internal/account"
|
||||||
|
"spend-sparrow/internal/authentication"
|
||||||
|
"spend-sparrow/internal/budget"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/dashboard"
|
||||||
|
"spend-sparrow/internal/handler"
|
||||||
|
"spend-sparrow/internal/handler/middleware"
|
||||||
|
"spend-sparrow/internal/tag"
|
||||||
|
"spend-sparrow/internal/transaction"
|
||||||
|
"spend-sparrow/internal/transaction_recurring"
|
||||||
|
"spend-sparrow/internal/treasure_chest"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,8 +31,8 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
|
|||||||
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
otelEnabled := types.IsOtelEnabled(env)
|
isOtelEnabled := core.IsOtelEnabled(env)
|
||||||
if otelEnabled {
|
if isOtelEnabled {
|
||||||
// use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled
|
// use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled
|
||||||
otelShutdown, err := setupOTelSDK(context.Background())
|
otelShutdown, err := setupOTelSDK(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,19 +48,19 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
slog.SetDefault(log.NewLogPropagator())
|
slog.SetDefault(core.NewLogPropagator())
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "Starting server...")
|
slog.InfoContext(ctx, "Starting server...")
|
||||||
|
|
||||||
// init server settings
|
// init server settings
|
||||||
serverSettings, err := types.NewSettingsFromEnv(ctx, env)
|
serverSettings, err := core.NewSettingsFromEnv(ctx, env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// init db
|
// init db
|
||||||
err = db.RunMigrations(ctx, database, migrationsPrefix)
|
err = core.RunMigrations(ctx, database, migrationsPrefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not run migrations: %w", err)
|
return fmt.Errorf("could not run migrations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +68,7 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
|
|||||||
// init server
|
// init server
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + serverSettings.Port,
|
Addr: ":" + serverSettings.Port,
|
||||||
Handler: createHandlerWithServices(ctx, database, serverSettings),
|
Handler: createHandlerWithServices(ctx, database, serverSettings, isOtelEnabled),
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
}
|
}
|
||||||
go startServer(ctx, httpServer)
|
go startServer(ctx, httpServer)
|
||||||
@@ -103,32 +107,38 @@ func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler {
|
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *core.Settings, isOtelEnabled bool) http.Handler {
|
||||||
var router = http.NewServeMux()
|
var router = http.NewServeMux()
|
||||||
|
|
||||||
authDb := db.NewAuthSqlite(d)
|
authDb := authentication.NewDbSqlite(d)
|
||||||
|
budgetDb := budget.NewDbSqlite(d)
|
||||||
|
tagDb := tag.NewDbSqlite(d)
|
||||||
|
|
||||||
randomService := service.NewRandom()
|
randomService := core.NewRandom()
|
||||||
clockService := service.NewClock()
|
clockService := core.NewClock()
|
||||||
mailService := service.NewMail(serverSettings)
|
mailService := core.NewMail(serverSettings)
|
||||||
|
|
||||||
authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings)
|
authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings)
|
||||||
accountService := service.NewAccount(d, randomService, clockService)
|
accountService := account.NewServiceImpl(d, randomService, clockService)
|
||||||
treasureChestService := service.NewTreasureChest(d, randomService, clockService)
|
treasureChestService := treasure_chest.NewService(d, randomService, clockService)
|
||||||
transactionService := service.NewTransaction(d, randomService, clockService)
|
transactionService := transaction.NewService(d, randomService, clockService)
|
||||||
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
|
transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService)
|
||||||
dashboardService := service.NewDashboard(d)
|
dashboardService := dashboard.NewService(d)
|
||||||
|
budgetService := budget.NewService(budgetDb, randomService, clockService)
|
||||||
|
tagService := tag.NewService(tagDb, randomService, clockService)
|
||||||
|
|
||||||
render := handler.NewRender()
|
render := core.NewRender()
|
||||||
indexHandler := handler.NewIndex(render, clockService)
|
indexHandler := handler.NewIndex(render, clockService)
|
||||||
dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService)
|
dashboardHandler := dashboard.NewHandler(render, dashboardService, treasureChestService)
|
||||||
authHandler := handler.NewAuth(authService, render)
|
authHandler := authentication.NewHandler(authService, render)
|
||||||
accountHandler := handler.NewAccount(accountService, render)
|
accountHandler := account.NewHandler(accountService, render)
|
||||||
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
|
treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render)
|
||||||
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render)
|
transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render)
|
||||||
transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render)
|
transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render)
|
||||||
|
budgetHandler := budget.NewHandler(budgetService, render)
|
||||||
|
tagHandler := tag.NewHandler(tagService, render)
|
||||||
|
|
||||||
go dailyTaskTimer(ctx, transactionRecurringService, authService)
|
go dailyTaskTimer(ctx, transactionService, authService)
|
||||||
|
|
||||||
indexHandler.Handle(router)
|
indexHandler.Handle(router)
|
||||||
dashboardHandler.Handle(router)
|
dashboardHandler.Handle(router)
|
||||||
@@ -137,6 +147,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
|||||||
authHandler.Handle(router)
|
authHandler.Handle(router)
|
||||||
transactionHandler.Handle(router)
|
transactionHandler.Handle(router)
|
||||||
transactionRecurringHandler.Handle(router)
|
transactionRecurringHandler.Handle(router)
|
||||||
|
budgetHandler.Handle(router)
|
||||||
|
tagHandler.Handle(router)
|
||||||
|
|
||||||
// Serve static files (CSS, JS and images)
|
// Serve static files (CSS, JS and images)
|
||||||
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
|
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
|
||||||
@@ -148,7 +160,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
|||||||
middleware.CrossSiteRequestForgery(authService),
|
middleware.CrossSiteRequestForgery(authService),
|
||||||
middleware.Authenticate(authService),
|
middleware.Authenticate(authService),
|
||||||
middleware.Gzip,
|
middleware.Gzip,
|
||||||
middleware.Log,
|
middleware.Log(isOtelEnabled),
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = otelhttp.NewHandler(wrapper, "http.request")
|
wrapper = otelhttp.NewHandler(wrapper, "http.request")
|
||||||
@@ -156,8 +168,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
|||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTaskTimer(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
|
func dailyTaskTimer(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
|
||||||
runDailyTasks(ctx, transactionRecurring, auth)
|
runDailyTasks(ctx, transaction, auth)
|
||||||
ticker := time.NewTicker(24 * time.Hour)
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -166,13 +178,13 @@ func dailyTaskTimer(ctx context.Context, transactionRecurring service.Transactio
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runDailyTasks(ctx, transactionRecurring, auth)
|
runDailyTasks(ctx, transaction, auth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDailyTasks(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
|
func runDailyTasks(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
|
||||||
slog.InfoContext(ctx, "Running daily tasks")
|
slog.InfoContext(ctx, "Running daily tasks")
|
||||||
_ = transactionRecurring.GenerateTransactions(ctx)
|
_ = transaction.GenerateRecurringTransactions(ctx)
|
||||||
_ = auth.CleanupSessionsAndTokens(ctx)
|
_ = auth.CleanupSessionsAndTokens(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"spend-sparrow/internal/db"
|
|
||||||
"spend-sparrow/internal/service"
|
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
func handleError(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, service.ErrUnauthorized):
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
case errors.Is(err, service.ErrBadRequest):
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
case errors.Is(err, db.ErrNotFound):
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractErrorMessage(err error) string {
|
|
||||||
errMsg := err.Error()
|
|
||||||
if errMsg == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.SplitN(errMsg, ":", 2)[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateSpan(r *http.Request) {
|
|
||||||
currentSpan := trace.SpanFromContext(r.Context())
|
|
||||||
if currentSpan != nil {
|
|
||||||
currentSpan.SetAttributes(attribute.String("http.pattern", r.Pattern))
|
|
||||||
currentSpan.SetAttributes(attribute.String("http.pattern.id", r.PathValue("id")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,18 +3,12 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"spend-sparrow/internal/authentication"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"spend-sparrow/internal/service"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextKey string
|
func Authenticate(service authentication.Service) func(http.Handler) http.Handler {
|
||||||
|
|
||||||
var SessionKey ContextKey = "session"
|
|
||||||
var UserKey ContextKey = "user"
|
|
||||||
|
|
||||||
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -37,46 +31,18 @@ func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie := CreateSessionCookie(session.Id)
|
cookie := core.CreateSessionCookie(session.Id)
|
||||||
http.SetCookie(w, &cookie)
|
http.SetCookie(w, &cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, UserKey, user)
|
ctx = context.WithValue(ctx, core.UserKey, user)
|
||||||
ctx = context.WithValue(ctx, SessionKey, session)
|
ctx = context.WithValue(ctx, core.SessionKey, session)
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUser(r *http.Request) *types.User {
|
|
||||||
obj := r.Context().Value(UserKey)
|
|
||||||
if obj == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, ok := obj.(*types.User)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSession(r *http.Request) *types.Session {
|
|
||||||
obj := r.Context().Value(SessionKey)
|
|
||||||
if obj == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
session, ok := obj.(*types.Session)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSessionID(r *http.Request) string {
|
func getSessionID(r *http.Request) string {
|
||||||
cookie, err := r.Cookie("id")
|
cookie, err := r.Cookie("id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/authentication"
|
||||||
"spend-sparrow/internal/utils"
|
"spend-sparrow/internal/core"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
|
|||||||
return rr.ResponseWriter.Write([]byte(dataStr))
|
return rr.ResponseWriter.Write([]byte(dataStr))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler {
|
func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -40,7 +40,7 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session := GetSession(r)
|
session := core.GetSession(r)
|
||||||
|
|
||||||
if r.Method == http.MethodPost ||
|
if r.Method == http.MethodPost ||
|
||||||
r.Method == http.MethodPut ||
|
r.Method == http.MethodPut ||
|
||||||
@@ -51,7 +51,7 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
|
|||||||
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) {
|
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) {
|
||||||
slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken)
|
slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken)
|
||||||
if r.Header.Get("Hx-Request") == "true" {
|
if r.Header.Get("Hx-Request") == "true" {
|
||||||
utils.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
|
core.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
|
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
|
|||||||
token, err := auth.GetCsrfToken(ctx, session)
|
token, err := auth.GetCsrfToken(ctx, session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if r.Header.Get("Hx-Request") == "true" {
|
if r.Header.Get("Hx-Request") == "true" {
|
||||||
utils.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest)
|
core.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
|
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
func CreateSessionCookie(sessionId string) http.Cookie {
|
|
||||||
return http.Cookie{
|
|
||||||
Name: "id",
|
|
||||||
Value: sessionId,
|
|
||||||
MaxAge: 60 * 60 * 8, // 8 hours
|
|
||||||
Secure: true,
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
Path: "/",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,21 +17,28 @@ func (w *WrappedWriter) WriteHeader(code int) {
|
|||||||
w.ResponseWriter.WriteHeader(code)
|
w.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Log(next http.Handler) http.Handler {
|
func Log(enabled bool) func(http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return func(next http.Handler) http.Handler {
|
||||||
start := time.Now()
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !enabled {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
wrapped := &WrappedWriter{
|
start := time.Now()
|
||||||
ResponseWriter: w,
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
}
|
|
||||||
next.ServeHTTP(wrapped, r)
|
|
||||||
|
|
||||||
slog.InfoContext(r.Context(), "request",
|
wrapped := &WrappedWriter{
|
||||||
"remoteAddr", r.RemoteAddr,
|
ResponseWriter: w,
|
||||||
"status", wrapped.StatusCode,
|
StatusCode: http.StatusOK,
|
||||||
"method", r.Method,
|
}
|
||||||
"path", r.URL.Path,
|
next.ServeHTTP(wrapped, r)
|
||||||
"duration", time.Since(start).String())
|
|
||||||
})
|
slog.InfoContext(r.Context(), "request",
|
||||||
|
"remoteAddr", r.RemoteAddr,
|
||||||
|
"status", wrapped.StatusCode,
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"duration", time.Since(start).String())
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
|
func SecurityHeaders(serverSettings *core.Settings) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// Chain list of handlers together.
|
// Wrapper wraps a list of handlers together.
|
||||||
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
|
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
lastHandler := next
|
lastHandler := next
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/handler/middleware"
|
"spend-sparrow/internal/core"
|
||||||
"spend-sparrow/internal/service"
|
|
||||||
"spend-sparrow/internal/template"
|
"spend-sparrow/internal/template"
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
@@ -15,11 +13,11 @@ type Index interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IndexImpl struct {
|
type IndexImpl struct {
|
||||||
r *Render
|
r *core.Render
|
||||||
c service.Clock
|
c core.Clock
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIndex(r *Render, c service.Clock) Index {
|
func NewIndex(r *core.Render, c core.Clock) Index {
|
||||||
return IndexImpl{
|
return IndexImpl{
|
||||||
r: r,
|
r: r,
|
||||||
c: c,
|
c: c,
|
||||||
@@ -33,11 +31,11 @@ func (handler IndexImpl) Handle(router *http.ServeMux) {
|
|||||||
|
|
||||||
func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
|
|
||||||
htmx := utils.IsHtmx(r)
|
htmx := core.IsHtmx(r)
|
||||||
|
|
||||||
var comp templ.Component
|
var comp templ.Component
|
||||||
|
|
||||||
@@ -47,7 +45,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
|||||||
status = http.StatusNotFound
|
status = http.StatusNotFound
|
||||||
} else {
|
} else {
|
||||||
if user != nil {
|
if user != nil {
|
||||||
utils.DoRedirect(w, r, "/dashboard")
|
core.DoRedirect(w, r, "/dashboard")
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
comp = template.Index()
|
comp = template.Index()
|
||||||
@@ -65,7 +63,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
|
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
// Return nothing
|
// Return nothing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ import (
|
|||||||
"go.opentelemetry.io/otel/sdk/metric"
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
"go.opentelemetry.io/otel/sdk/resource"
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
otelEndpoint = "otel-collector:4317"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||||
@@ -50,10 +45,7 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|||||||
prop := newPropagator()
|
prop := newPropagator()
|
||||||
otel.SetTextMapPropagator(prop)
|
otel.SetTextMapPropagator(prop)
|
||||||
|
|
||||||
resources, err := resource.New(
|
resources, err := resource.New(ctx)
|
||||||
ctx,
|
|
||||||
resource.WithAttributes(semconv.ServiceName("spend-sparrow")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to create resource", "error", err)
|
slog.ErrorContext(ctx, "failed to create resource", "error", err)
|
||||||
}
|
}
|
||||||
@@ -96,11 +88,7 @@ func newPropagator() propagation.TextMapPropagator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace.TracerProvider, error) {
|
func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace.TracerProvider, error) {
|
||||||
exp, err := otlptracegrpc.New(
|
exp, err := otlptracegrpc.New(ctx)
|
||||||
ctx,
|
|
||||||
otlptracegrpc.WithEndpoint(otelEndpoint),
|
|
||||||
otlptracegrpc.WithInsecure(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -112,10 +100,7 @@ func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric.MeterProvider, error) {
|
func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric.MeterProvider, error) {
|
||||||
exp, err := otlpmetricgrpc.New(
|
exp, err := otlpmetricgrpc.New(ctx)
|
||||||
ctx,
|
|
||||||
otlpmetricgrpc.WithInsecure(),
|
|
||||||
otlpmetricgrpc.WithEndpoint(otelEndpoint))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -127,10 +112,7 @@ func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newLoggerProvider(ctx context.Context, resource *resource.Resource) (*log.LoggerProvider, error) {
|
func newLoggerProvider(ctx context.Context, resource *resource.Resource) (*log.LoggerProvider, error) {
|
||||||
logExporter, err := otlploggrpc.New(
|
logExporter, err := otlploggrpc.New(ctx)
|
||||||
ctx,
|
|
||||||
otlploggrpc.WithInsecure(),
|
|
||||||
otlploggrpc.WithEndpoint(otelEndpoint))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrBadRequest = errors.New("bad request")
|
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
|
||||||
)
|
|
||||||
111
internal/tag/db.go
Normal file
111
internal/tag/db.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Db interface {
|
||||||
|
insert(ctx context.Context, tag Tag) (*Tag, error)
|
||||||
|
update(ctx context.Context, tag Tag) (*Tag, error)
|
||||||
|
delete(ctx context.Context, userId uuid.UUID, id uuid.UUID) error
|
||||||
|
get(ctx context.Context, userId uuid.UUID, id uuid.UUID) (*Tag, error)
|
||||||
|
getAll(ctx context.Context, userId uuid.UUID) ([]Tag, error)
|
||||||
|
find(ctx context.Context, userId uuid.UUID, search string) ([]Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DbSqlite struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDbSqlite(db *sqlx.DB) *DbSqlite {
|
||||||
|
return &DbSqlite{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) insert(ctx context.Context, tag Tag) (*Tag, error) {
|
||||||
|
r, err := db.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO tag (id, user_id, name, created_at, created_by, updated_at, updated_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
tag.Id, tag.UserId, tag.Name, tag.CreatedAt, tag.CreatedBy, tag.UpdatedAt, tag.UpdatedBy,
|
||||||
|
)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "tag", r, err)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.get(ctx, tag.UserId, tag.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) update(ctx context.Context, tag Tag) (*Tag, error) {
|
||||||
|
_, err := db.db.ExecContext(ctx, `
|
||||||
|
UPDATE tag
|
||||||
|
SET name = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
updated_by = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND id = ?`,
|
||||||
|
tag.Name, tag.UpdatedAt, tag.UpdatedBy, tag.UserId, tag.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.get(ctx, tag.UserId, tag.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) delete(ctx context.Context, userId uuid.UUID, id uuid.UUID) error {
|
||||||
|
r, err := db.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"DELETE FROM tag WHERE user_id = ? AND id = ?",
|
||||||
|
userId,
|
||||||
|
id)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "tag", r, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) get(ctx context.Context, userId uuid.UUID, id uuid.UUID) (*Tag, error) {
|
||||||
|
var tag Tag
|
||||||
|
err := db.db.GetContext(ctx, &tag, "SELECT * FROM tag WHERE id = ? AND user_id = ?", id, userId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Could not get tag", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) getAll(ctx context.Context, userId uuid.UUID) ([]Tag, error) {
|
||||||
|
var tags []Tag
|
||||||
|
err := db.db.SelectContext(ctx, &tags, "SELECT * FROM tag WHERE user_id = ?", userId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Could not GetAll tag", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db DbSqlite) find(ctx context.Context, userId uuid.UUID, search string) ([]Tag, error) {
|
||||||
|
var tags []Tag
|
||||||
|
err := db.db.SelectContext(ctx, &tags, "SELECT * FROM tag WHERE user_id = ? AND name LIKE ?", userId, "%"+search+"%")
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "find", "len", len(tags), "search", search)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Could not find tag", "err", err)
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
200
internal/tag/handler.go
Normal file
200
internal/tag/handler.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DECIMALS_MULTIPLIER = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerImpl struct {
|
||||||
|
s Service
|
||||||
|
r *core.Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(s Service, r *core.Render) Handler {
|
||||||
|
return HandlerImpl{
|
||||||
|
s: s,
|
||||||
|
r: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||||
|
r.Handle("GET /tag", h.handlePage())
|
||||||
|
r.Handle("POST /tag/search", h.handleInlineEditSearch())
|
||||||
|
r.Handle("GET /tag/new", h.handleNew())
|
||||||
|
r.Handle("GET /tag/{id}", h.handleEdit())
|
||||||
|
r.Handle("POST /tag/{id}", h.handlePost())
|
||||||
|
r.Handle("DELETE /tag/{id}", h.handleDelete())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handlePage() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := h.s.getAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
h.r.RenderLayout(r, w, core.ErrorComp(err), user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := page(tags)
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h HandlerImpl) handleInlineEditSearch() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
search := r.FormValue("search")
|
||||||
|
tags, err := h.s.find(r.Context(), user, search)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not find tags: %w", core.ErrInternal))
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := inlineEditSearch(tags)
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handleNew() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := editNew()
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handleEdit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := h.s.get(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := edit(*tag)
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handlePost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr != "new" {
|
||||||
|
id, err = uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := Tag{
|
||||||
|
Id: id,
|
||||||
|
Name: r.FormValue("name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if idStr == "new" {
|
||||||
|
_, err = h.s.add(r.Context(), user, input)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = h.s.update(r.Context(), user, input)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core.DoRedirect(w, r, "/tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerImpl) handleDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
|
user := core.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.s.delete(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
core.HandleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
core.DoRedirect(w, r, "/tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
119
internal/tag/service.go
Normal file
119
internal/tag/service.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"spend-sparrow/internal/auth_types"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
add(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error)
|
||||||
|
update(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error)
|
||||||
|
delete(ctx context.Context, user *auth_types.User, tagId uuid.UUID) error
|
||||||
|
get(ctx context.Context, user *auth_types.User, tagId uuid.UUID) (*Tag, error)
|
||||||
|
getAll(ctx context.Context, user *auth_types.User) ([]Tag, error)
|
||||||
|
find(ctx context.Context, user *auth_types.User, search string) ([]Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceImpl struct {
|
||||||
|
db Db
|
||||||
|
clock core.Clock
|
||||||
|
random core.Random
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db Db, random core.Random, clock core.Clock) Service {
|
||||||
|
return ServiceImpl{
|
||||||
|
db: db,
|
||||||
|
clock: clock,
|
||||||
|
random: random,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) add(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid := s.isTagValid(tag)
|
||||||
|
if !isValid {
|
||||||
|
return nil, core.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
newId, err := s.random.UUID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.Id = newId
|
||||||
|
tag.UserId = user.Id
|
||||||
|
tag.CreatedBy = user.Id
|
||||||
|
tag.CreatedAt = s.clock.Now()
|
||||||
|
|
||||||
|
return s.db.insert(ctx, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) update(ctx context.Context, user *auth_types.User, input Tag) (*Tag, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.get(ctx, user, input.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.Name = input.Name
|
||||||
|
|
||||||
|
if user.Id != tag.UserId {
|
||||||
|
return nil, core.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid := s.isTagValid(*tag)
|
||||||
|
if !isValid {
|
||||||
|
return nil, core.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.UpdatedBy = &user.Id
|
||||||
|
now := s.clock.Now()
|
||||||
|
tag.UpdatedAt = &now
|
||||||
|
|
||||||
|
return s.db.update(ctx, *tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) delete(ctx context.Context, user *auth_types.User, tagId uuid.UUID) error {
|
||||||
|
if user == nil {
|
||||||
|
return core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.delete(ctx, user.Id, tagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) get(ctx context.Context, user *auth_types.User, tagId uuid.UUID) (*Tag, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.get(ctx, user.Id, tagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) getAll(ctx context.Context, user *auth_types.User) ([]Tag, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.getAll(ctx, user.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) find(ctx context.Context, user *auth_types.User, search string) ([]Tag, error) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, core.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.find(ctx, user.Id, search)
|
||||||
|
}
|
||||||
|
func (s ServiceImpl) isTagValid(tag Tag) bool {
|
||||||
|
err := core.ValidateString(tag.Name, "name")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
159
internal/tag/template.templ
Normal file
159
internal/tag/template.templ
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/template/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ InlineEditInput(classes string) {
|
||||||
|
<div class={ "flex flex-wrap gap-2 input max-w-full " + classes }>
|
||||||
|
<span class="flex items-center gap-1 p-1 bg-green-100 rounded-sm">
|
||||||
|
Lebensmittel
|
||||||
|
<button class="hover:bg-red-900 rounded p-1 w-5 transition-all">
|
||||||
|
@svg.Cancel()
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 p-1 bg-yellow-100 rounded-sm">
|
||||||
|
Sparen
|
||||||
|
<button class="hover:bg-red-900 rounded p-1 w-5 transition-all">
|
||||||
|
@svg.Cancel()
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 p-1 bg-red-100 rounded-sm">
|
||||||
|
Tanken
|
||||||
|
<button class="hover:bg-red-900 rounded p-1 w-5 transition-all">
|
||||||
|
@svg.Cancel()
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
class="inline"
|
||||||
|
name="search"
|
||||||
|
placeholder="Begin Typing To Search ..."
|
||||||
|
hx-post="/tag/search"
|
||||||
|
hx-trigger="input changed delay:250ms, keyup[key=='Enter'], load"
|
||||||
|
hx-target="#tag-search-results"
|
||||||
|
/>
|
||||||
|
<div id="tag-search-results" class="absolute bg-white border-gray-200 border-1 rounded-lg p-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ inlineEditSearch(tags []Tag) {
|
||||||
|
for _,tag:=range(tags) {
|
||||||
|
<p x-data={ tag.Id.String() }>{ tag.Name }</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ page(tags []Tag) {
|
||||||
|
@core.Breadcrumb([]string{"Home", "Tag"}, []string{"/", "/tag"})
|
||||||
|
<div class="flex flex-wrap gap-20 text-xl mt-10 justify-center">
|
||||||
|
@newItem()
|
||||||
|
for _,tag:=range(tags ) {
|
||||||
|
@item(tag)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ editNew() {
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
@core.Breadcrumb([]string{"Home", "Tag", "New"}, []string{"/", "/tag", "/tag/new"})
|
||||||
|
<div class="flex justify-center items-center flex-1">
|
||||||
|
<form
|
||||||
|
hx-post={ "/tag/new" }
|
||||||
|
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
|
||||||
|
>
|
||||||
|
<label for="timestamp" class="text-sm text-gray-500">Name</label>
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
class="bg-white input datetime col-span-3"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-6 justify-end col-span-4">
|
||||||
|
<a href="/tag" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
<span class="h-4 w-4">
|
||||||
|
@svg.Cancel()
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
@svg.Save()
|
||||||
|
<span>
|
||||||
|
Save
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ edit(tag Tag) {
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
@core.Breadcrumb([]string{"Home", "Tag", tag.Name}, []string{"/", "/tag", "/tag/" + tag.Id.String()})
|
||||||
|
<div class="flex justify-center items-center flex-1">
|
||||||
|
<form
|
||||||
|
hx-post={ "/tag/" + tag.Id.String() }
|
||||||
|
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
|
||||||
|
>
|
||||||
|
<label for="timestamp" class="text-sm text-gray-500">Name</label>
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={ tag.Name }
|
||||||
|
class="bg-white input datetime col-span-3"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-row-reverse gap-6 justify-end col-span-4">
|
||||||
|
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
@svg.Save()
|
||||||
|
<span>
|
||||||
|
Save
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<a href="/tag" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
|
||||||
|
<span class="h-4 w-4">
|
||||||
|
@svg.Cancel()
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
hx-delete={ "/tag/" + tag.Id.String() }
|
||||||
|
hx-confirm={ "Do you really want to delete '" + tag.Name + "'" }
|
||||||
|
class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-red-50 rounded-lg hover:underline flex items-center gap-2 justify-center"
|
||||||
|
>
|
||||||
|
@svg.Delete()
|
||||||
|
<span>
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ newItem() {
|
||||||
|
<a
|
||||||
|
href="/tag/new"
|
||||||
|
class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300"
|
||||||
|
>
|
||||||
|
New Tag
|
||||||
|
<div class="w-10">
|
||||||
|
@svg.Plus()
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ item(tag Tag) {
|
||||||
|
<a href={ "/tag/" + tag.Id.String() } class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300">
|
||||||
|
<span>
|
||||||
|
{ tag.Name }
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
19
internal/tag/types.go
Normal file
19
internal/tag/types.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
Id uuid.UUID `db:"id"`
|
||||||
|
UserId uuid.UUID `db:"user_id"`
|
||||||
|
|
||||||
|
Name string `db:"name"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
CreatedBy uuid.UUID `db:"created_by"`
|
||||||
|
UpdatedAt *time.Time `db:"updated_at"`
|
||||||
|
UpdatedBy *uuid.UUID `db:"updated_by"`
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
package account
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package auth
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
package dashboard
|
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ templ Eye() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ Plus() {
|
templ Plus() {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="h-4 w-4 text-gray-500">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="text-gray-500">
|
||||||
<path fill="currentColor" d="M299 213H171v128h-43V213H0v-42h128V43h43v128h128v42z"></path>
|
<path fill="currentColor" d="M299 213H171v128h-43V213H0v-42h128V43h43v128h128v42z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
package transaction
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package transaction_recurring
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package treasurechest
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
package handler
|
package transaction
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/handler/middleware"
|
"spend-sparrow/internal/account"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/core"
|
||||||
t "spend-sparrow/internal/template/transaction"
|
"spend-sparrow/internal/treasure_chest"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,19 +15,23 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Transaction interface {
|
const (
|
||||||
|
DECIMALS_MULTIPLIER = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler interface {
|
||||||
Handle(router *http.ServeMux)
|
Handle(router *http.ServeMux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionImpl struct {
|
type HandlerImpl struct {
|
||||||
s service.Transaction
|
s Service
|
||||||
account service.Account
|
account account.Service
|
||||||
treasureChest service.TreasureChest
|
treasureChest treasure_chest.Service
|
||||||
r *Render
|
r *core.Render
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, r *Render) Transaction {
|
func NewHandler(s Service, account account.Service, treasureChest treasure_chest.Service, r *core.Render) Handler {
|
||||||
return TransactionImpl{
|
return HandlerImpl{
|
||||||
s: s,
|
s: s,
|
||||||
account: account,
|
account: account,
|
||||||
treasureChest: treasureChest,
|
treasureChest: treasureChest,
|
||||||
@@ -36,7 +39,7 @@ func NewTransaction(s service.Transaction, account service.Account, treasureChes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) Handle(r *http.ServeMux) {
|
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||||
r.Handle("GET /transaction", h.handleTransactionPage())
|
r.Handle("GET /transaction", h.handleTransactionPage())
|
||||||
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
|
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
|
||||||
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
|
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
|
||||||
@@ -44,17 +47,17 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
|
|||||||
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
|
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
func (h HandlerImpl) handleTransactionPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filter := types.TransactionItemsFilter{
|
filter := TransactionItemsFilter{
|
||||||
AccountId: r.URL.Query().Get("account-id"),
|
AccountId: r.URL.Query().Get("account-id"),
|
||||||
TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
|
TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
|
||||||
Error: r.URL.Query().Get("error"),
|
Error: r.URL.Query().Get("error"),
|
||||||
@@ -63,87 +66,87 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
|||||||
|
|
||||||
transactions, err := h.s.GetAll(r.Context(), user, filter)
|
transactions, err := h.s.GetAll(r.Context(), user, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, err := h.account.GetAll(r.Context(), user)
|
accounts, err := h.account.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||||
|
|
||||||
items := t.TransactionItems(transactions, accountMap, treasureChestMap)
|
items := TransactionItems(transactions, accountMap, treasureChestMap)
|
||||||
if utils.IsHtmx(r) {
|
if core.IsHtmx(r) {
|
||||||
h.r.Render(r, w, items)
|
h.r.Render(r, w, items)
|
||||||
} else {
|
} else {
|
||||||
comp := t.Transaction(items, filter, accounts, treasureChests)
|
comp := TransactionComp(items, filter, accounts, treasureChests)
|
||||||
h.r.RenderLayout(r, w, comp, user)
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
|
func (h HandlerImpl) handleTransactionItemComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, err := h.account.GetAll(r.Context(), user)
|
accounts, err := h.account.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if id == "new" {
|
if id == "new" {
|
||||||
comp := t.EditTransaction(nil, accounts, treasureChests)
|
comp := EditTransaction(nil, accounts, treasureChests)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction, err := h.s.Get(r.Context(), user, id)
|
transaction, err := h.s.Get(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var comp templ.Component
|
var comp templ.Component
|
||||||
if r.URL.Query().Get("edit") == "true" {
|
if r.URL.Query().Get("edit") == "true" {
|
||||||
comp = t.EditTransaction(transaction, accounts, treasureChests)
|
comp = EditTransaction(transaction, accounts, treasureChests)
|
||||||
} else {
|
} else {
|
||||||
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||||
comp = t.TransactionItem(transaction, accountMap, treasureChestMap)
|
comp = TransactionItem(transaction, accountMap, treasureChestMap)
|
||||||
}
|
}
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +159,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
|||||||
if idStr != "new" {
|
if idStr != "new" {
|
||||||
id, err = uuid.Parse(idStr)
|
id, err = uuid.Parse(idStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest))
|
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +169,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
|||||||
if accountIdStr != "" {
|
if accountIdStr != "" {
|
||||||
i, err := uuid.Parse(accountIdStr)
|
i, err := uuid.Parse(accountIdStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, fmt.Errorf("could not parse account id: %w", service.ErrBadRequest))
|
core.HandleError(w, r, fmt.Errorf("could not parse account id: %w", core.ErrBadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accountId = &i
|
accountId = &i
|
||||||
@@ -177,7 +180,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
|||||||
if treasureChestIdStr != "" {
|
if treasureChestIdStr != "" {
|
||||||
i, err := uuid.Parse(treasureChestIdStr)
|
i, err := uuid.Parse(treasureChestIdStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", service.ErrBadRequest))
|
core.HandleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", core.ErrBadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
treasureChestId = &i
|
treasureChestId = &i
|
||||||
@@ -185,18 +188,18 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
|||||||
|
|
||||||
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
|
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, fmt.Errorf("could not parse value: %w", service.ErrBadRequest))
|
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
value := int64(math.Round(valueF * service.DECIMALS_MULTIPLIER))
|
value := int64(math.Round(valueF * DECIMALS_MULTIPLIER))
|
||||||
|
|
||||||
timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp"))
|
timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest))
|
core.HandleError(w, r, fmt.Errorf("could not parse timestamp: %w", core.ErrBadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input := types.Transaction{
|
input := Transaction{
|
||||||
Id: id,
|
Id: id,
|
||||||
AccountId: accountId,
|
AccountId: accountId,
|
||||||
TreasureChestId: treasureChestId,
|
TreasureChestId: treasureChestId,
|
||||||
@@ -206,66 +209,66 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
|||||||
Description: r.FormValue("description"),
|
Description: r.FormValue("description"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var transaction *types.Transaction
|
var transaction *Transaction
|
||||||
if idStr == "new" {
|
if idStr == "new" {
|
||||||
transaction, err = h.s.Add(r.Context(), nil, user, input)
|
transaction, err = h.s.Add(r.Context(), nil, user, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
transaction, err = h.s.Update(r.Context(), user, input)
|
transaction, err = h.s.Update(r.Context(), user, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, err := h.account.GetAll(r.Context(), user)
|
accounts, err := h.account.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||||
comp := t.TransactionItem(transaction, accountMap, treasureChestMap)
|
comp := TransactionItem(transaction, accountMap, treasureChestMap)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
|
func (h HandlerImpl) handleRecalculate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.s.RecalculateBalances(r.Context(), user)
|
err := h.s.RecalculateBalances(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
|
core.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
|
func (h HandlerImpl) handleDeleteTransaction() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,13 +276,13 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
|
|||||||
|
|
||||||
err := h.s.Delete(r.Context(), user, id)
|
err := h.s.Delete(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureChests []*types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
|
func (h HandlerImpl) getTransactionData(accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
|
||||||
accountMap := make(map[uuid.UUID]string, 0)
|
accountMap := make(map[uuid.UUID]string, 0)
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
accountMap[account.Id] = account.Name
|
accountMap[account.Id] = account.Name
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package service
|
package transaction
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/transaction_recurring"
|
||||||
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,33 +18,34 @@ import (
|
|||||||
|
|
||||||
const page_size = 25
|
const page_size = 25
|
||||||
|
|
||||||
type Transaction interface {
|
type Service interface {
|
||||||
Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transaction types.Transaction) (*types.Transaction, error)
|
Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transaction Transaction) (*Transaction, error)
|
||||||
Update(ctx context.Context, user *types.User, transaction types.Transaction) (*types.Transaction, error)
|
Update(ctx context.Context, user *auth_types.User, transaction Transaction) (*Transaction, error)
|
||||||
Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error)
|
Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error)
|
||||||
GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
|
GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error)
|
||||||
Delete(ctx context.Context, user *types.User, id string) error
|
Delete(ctx context.Context, user *auth_types.User, id string) error
|
||||||
|
|
||||||
RecalculateBalances(ctx context.Context, user *types.User) error
|
RecalculateBalances(ctx context.Context, user *auth_types.User) error
|
||||||
|
GenerateRecurringTransactions(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionImpl struct {
|
type ServiceImpl struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
clock Clock
|
clock core.Clock
|
||||||
random Random
|
random core.Random
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction {
|
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||||
return TransactionImpl{
|
return ServiceImpl{
|
||||||
db: db,
|
db: db,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
random: random,
|
random: random,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transactionInput types.Transaction) (*types.Transaction, error) {
|
func (s ServiceImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transactionInput Transaction) (*Transaction, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -50,7 +53,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
|
|||||||
if tx == nil {
|
if tx == nil {
|
||||||
ownsTransaction = true
|
ownsTransaction = true
|
||||||
tx, err = s.db.BeginTxx(ctx, nil)
|
tx, err = s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Add", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
|
|||||||
party, description, error, created_at, created_by)
|
party, description, error, created_at, created_by)
|
||||||
VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp,
|
VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp,
|
||||||
:party, :description, :error, :created_at, :created_by)`, transaction)
|
:party, :description, :error, :created_at, :created_by)`, transaction)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Insert", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Insert", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -79,7 +82,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
|
|||||||
UPDATE account
|
UPDATE account
|
||||||
SET current_balance = current_balance + ?
|
SET current_balance = current_balance + ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Add", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
|
|||||||
UPDATE treasure_chest
|
UPDATE treasure_chest
|
||||||
SET current_balance = current_balance + ?
|
SET current_balance = current_balance + ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Add", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -98,7 +101,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
|
|||||||
|
|
||||||
if ownsTransaction {
|
if ownsTransaction {
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Add", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -107,13 +110,13 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
|
|||||||
return transaction, nil
|
return transaction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) Update(ctx context.Context, user *types.User, input types.Transaction) (*types.Transaction, error) {
|
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Transaction) (*Transaction, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -121,14 +124,14 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
transaction := &types.Transaction{}
|
transaction := &Transaction{}
|
||||||
err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
|
err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest)
|
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
if transaction.Error == nil && transaction.AccountId != nil {
|
if transaction.Error == nil && transaction.AccountId != nil {
|
||||||
@@ -136,7 +139,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
UPDATE account
|
UPDATE account
|
||||||
SET current_balance = current_balance - ?
|
SET current_balance = current_balance - ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -146,7 +149,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
UPDATE treasure_chest
|
UPDATE treasure_chest
|
||||||
SET current_balance = current_balance - ?
|
SET current_balance = current_balance - ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -162,7 +165,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
UPDATE account
|
UPDATE account
|
||||||
SET current_balance = current_balance + ?
|
SET current_balance = current_balance + ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -172,7 +175,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
UPDATE treasure_chest
|
UPDATE treasure_chest
|
||||||
SET current_balance = current_balance + ?
|
SET current_balance = current_balance + ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -192,13 +195,13 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
updated_by = :updated_by
|
updated_by = :updated_by
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
AND user_id = :user_id`, transaction)
|
AND user_id = :user_id`, transaction)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -206,32 +209,32 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
|
|||||||
return transaction, nil
|
return transaction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) {
|
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transaction get", "err", err)
|
slog.ErrorContext(ctx, "transaction get", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
var transaction types.Transaction
|
var transaction Transaction
|
||||||
err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Get", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Get", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest)
|
return nil, fmt.Errorf("transaction %v not found: %w", id, core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return &transaction, nil
|
return &transaction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) {
|
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -249,7 +252,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter ty
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions := make([]*types.Transaction, 0)
|
transactions := make([]*Transaction, 0)
|
||||||
err = s.db.SelectContext(ctx, &transactions, `
|
err = s.db.SelectContext(ctx, &transactions, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM "transaction"
|
FROM "transaction"
|
||||||
@@ -269,7 +272,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter ty
|
|||||||
filter.Error,
|
filter.Error,
|
||||||
page_size,
|
page_size,
|
||||||
offset)
|
offset)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -277,18 +280,18 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter ty
|
|||||||
return transactions, nil
|
return transactions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string) error {
|
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ErrUnauthorized
|
return core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transaction delete", "err", err)
|
slog.ErrorContext(ctx, "transaction delete", "err", err)
|
||||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -296,9 +299,9 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var transaction types.Transaction
|
var transaction Transaction
|
||||||
err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -309,8 +312,8 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
|
|||||||
SET current_balance = current_balance - ?
|
SET current_balance = current_balance - ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,20 +324,20 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
|
|||||||
SET current_balance = current_balance - ?
|
SET current_balance = current_balance - ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
|
r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -342,13 +345,13 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.User) error {
|
func (s ServiceImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ErrUnauthorized
|
return core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -360,8 +363,8 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
UPDATE account
|
UPDATE account
|
||||||
SET current_balance = 0
|
SET current_balance = 0
|
||||||
WHERE user_id = ?`, user.Id)
|
WHERE user_id = ?`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +372,8 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
UPDATE treasure_chest
|
UPDATE treasure_chest
|
||||||
SET current_balance = 0
|
SET current_balance = 0
|
||||||
WHERE user_id = ?`, user.Id)
|
WHERE user_id = ?`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,8 +381,8 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM "transaction"
|
FROM "transaction"
|
||||||
WHERE user_id = ?`, user.Id)
|
WHERE user_id = ?`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -389,10 +392,10 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var transaction types.Transaction
|
var transaction Transaction
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
err = rows.StructScan(&transaction)
|
err = rows.StructScan(&transaction)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -403,7 +406,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
SET error = ?
|
SET error = ?
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND id = ?`, transaction.Error, user.Id, transaction.Id)
|
AND id = ?`, transaction.Error, user.Id, transaction.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -417,7 +420,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
UPDATE account
|
UPDATE account
|
||||||
SET current_balance = current_balance + ?
|
SET current_balance = current_balance + ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -427,7 +430,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
UPDATE treasure_chest
|
UPDATE treasure_chest
|
||||||
SET current_balance = current_balance + ?
|
SET current_balance = current_balance + ?
|
||||||
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -435,7 +438,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -443,7 +446,63 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) {
|
func (s ServiceImpl) GenerateRecurringTransactions(ctx context.Context) error {
|
||||||
|
now := s.clock.Now()
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
recurringTransactions := make([]*transaction_recurring.TransactionRecurring, 0)
|
||||||
|
err = tx.SelectContext(ctx, &recurringTransactions, `
|
||||||
|
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
|
||||||
|
now)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, transactionRecurring := range recurringTransactions {
|
||||||
|
user := &auth_types.User{
|
||||||
|
Id: transactionRecurring.UserId,
|
||||||
|
}
|
||||||
|
transaction := Transaction{
|
||||||
|
Timestamp: *transactionRecurring.NextExecution,
|
||||||
|
Party: transactionRecurring.Party,
|
||||||
|
Description: transactionRecurring.Description,
|
||||||
|
|
||||||
|
TreasureChestId: transactionRecurring.TreasureChestId,
|
||||||
|
Value: transactionRecurring.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Add(ctx, tx, user, transaction)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
|
||||||
|
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
|
||||||
|
nextExecution, transactionRecurring.Id, user.Id)
|
||||||
|
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", r, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *Transaction, userId uuid.UUID, input Transaction) (*Transaction, error) {
|
||||||
var (
|
var (
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
@@ -458,7 +517,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
|||||||
if oldTransaction == nil {
|
if oldTransaction == nil {
|
||||||
id, err = s.random.UUID(ctx)
|
id, err = s.random.UUID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
createdAt = s.clock.Now()
|
createdAt = s.clock.Now()
|
||||||
createdBy = userId
|
createdBy = userId
|
||||||
@@ -473,45 +532,45 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
|||||||
|
|
||||||
if input.AccountId != nil {
|
if input.AccountId != nil {
|
||||||
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId)
|
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction validate", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction validate", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if rowCount == 0 {
|
if rowCount == 0 {
|
||||||
slog.ErrorContext(ctx, "transaction validate", "err", err)
|
slog.ErrorContext(ctx, "transaction validate", "err", err)
|
||||||
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
|
return nil, fmt.Errorf("account not found: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.TreasureChestId != nil {
|
if input.TreasureChestId != nil {
|
||||||
var treasureChest types.TreasureChest
|
var treasureChest treasure_chest_types.TreasureChest
|
||||||
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId)
|
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId)
|
||||||
err = db.TransformAndLogDbError(ctx, "transaction validate", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transaction validate", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
|
return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if treasureChest.ParentId == nil {
|
if treasureChest.ParentId == nil {
|
||||||
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest)
|
return nil, fmt.Errorf("treasure chest is a group: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Party != "" {
|
if input.Party != "" {
|
||||||
err = validateString(input.Party, "party")
|
err = core.ValidateString(input.Party, "party")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if input.Description != "" {
|
if input.Description != "" {
|
||||||
err = validateString(input.Description, "description")
|
err = core.ValidateString(input.Description, "description")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction := types.Transaction{
|
transaction := Transaction{
|
||||||
Id: id,
|
Id: id,
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
|
|
||||||
@@ -534,7 +593,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
|||||||
return &transaction, nil
|
return &transaction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionImpl) updateErrors(t *types.Transaction) {
|
func (s ServiceImpl) updateErrors(t *Transaction) {
|
||||||
errorStr := ""
|
errorStr := ""
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package transaction
|
package transaction
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
import "time"
|
"fmt"
|
||||||
import "spend-sparrow/internal/template/svg"
|
"github.com/google/uuid"
|
||||||
import "spend-sparrow/internal/types"
|
"spend-sparrow/internal/account"
|
||||||
import "github.com/google/uuid"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/template/svg"
|
||||||
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
templ Transaction(items templ.Component, filter types.TransactionItemsFilter, accounts []*types.Account, treasureChests []*types.TreasureChest) {
|
templ TransactionComp(items templ.Component, filter TransactionItemsFilter, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
|
||||||
<div class="max-w-6xl mt-10 mx-auto">
|
<div class="max-w-6xl mt-10 mx-auto">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<form
|
<form
|
||||||
@@ -61,7 +65,9 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
|
|||||||
hx-swap="afterbegin"
|
hx-swap="afterbegin"
|
||||||
class="button button-primary ml-auto px-2 flex items-center gap-2 justify-center"
|
class="button button-primary ml-auto px-2 flex items-center gap-2 justify-center"
|
||||||
>
|
>
|
||||||
@svg.Plus()
|
<div class="w-3">
|
||||||
|
@svg.Plus()
|
||||||
|
</div>
|
||||||
<p>New Transaction</p>
|
<p>New Transaction</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +93,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ TransactionItems(transactions []*types.Transaction, accounts, treasureChests map[uuid.UUID]string) {
|
templ TransactionItems(transactions []*Transaction, accounts, treasureChests map[uuid.UUID]string) {
|
||||||
<div id="transaction-items" class="my-6">
|
<div id="transaction-items" class="my-6">
|
||||||
for _, transaction := range transactions {
|
for _, transaction := range transactions {
|
||||||
@TransactionItem(transaction, accounts, treasureChests)
|
@TransactionItem(transaction, accounts, treasureChests)
|
||||||
@@ -95,7 +101,7 @@ templ TransactionItems(transactions []*types.Transaction, accounts, treasureChes
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ EditTransaction(transaction *types.Transaction, accounts []*types.Account, treasureChests []*types.TreasureChest) {
|
templ EditTransaction(transaction *Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
|
||||||
{{
|
{{
|
||||||
var (
|
var (
|
||||||
timestamp time.Time
|
timestamp time.Time
|
||||||
@@ -219,7 +225,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ TransactionItem(transaction *types.Transaction, accounts, treasureChests map[uuid.UUID]string) {
|
templ TransactionItem(transaction *Transaction, accounts, treasureChests map[uuid.UUID]string) {
|
||||||
{{
|
{{
|
||||||
background := "bg-gray-50"
|
background := "bg-gray-50"
|
||||||
if transaction.Error != nil {
|
if transaction.Error != nil {
|
||||||
@@ -272,9 +278,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
if transaction.Value < 0 {
|
if transaction.Value < 0 {
|
||||||
<p class="mr-8 min-w-22 text-right text-red-700">{ types.FormatEuros(transaction.Value) }</p>
|
<p class="mr-8 min-w-22 text-right text-red-700">{ core.FormatEuros(transaction.Value) }</p>
|
||||||
} else {
|
} else {
|
||||||
<p class="mr-8 w-22 text-right text-green-700">{ types.FormatEuros(transaction.Value) }</p>
|
<p class="mr-8 w-22 text-right text-green-700">{ core.FormatEuros(transaction.Value) }</p>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
|
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package transaction
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// At the center of the application is the transaction.
|
// Transaction is at the center of the application.
|
||||||
//
|
//
|
||||||
// Every piece of data should be calculated based on transactions.
|
// Every piece of data should be calculated based on transactions.
|
||||||
// This means potential calculation errors can be fixed later in time.
|
// This means potential calculation errors can be fixed later in time.
|
||||||
@@ -1,43 +1,40 @@
|
|||||||
package handler
|
package transaction_recurring
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/handler/middleware"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/core"
|
||||||
t "spend-sparrow/internal/template/transaction_recurring"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TransactionRecurring interface {
|
type Handler interface {
|
||||||
Handle(router *http.ServeMux)
|
Handle(router *http.ServeMux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionRecurringImpl struct {
|
type HandlerImpl struct {
|
||||||
s service.TransactionRecurring
|
s Service
|
||||||
r *Render
|
r *core.Render
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransactionRecurring(s service.TransactionRecurring, r *Render) TransactionRecurring {
|
func NewHandler(s Service, r *core.Render) Handler {
|
||||||
return TransactionRecurringImpl{
|
return HandlerImpl{
|
||||||
s: s,
|
s: s,
|
||||||
r: r,
|
r: r,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionRecurringImpl) Handle(r *http.ServeMux) {
|
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||||
r.Handle("GET /transaction-recurring", h.handleTransactionRecurringItemComp())
|
r.Handle("GET /transaction-recurring", h.handleTransactionRecurringItemComp())
|
||||||
r.Handle("POST /transaction-recurring/{id}", h.handleUpdateTransactionRecurring())
|
r.Handle("POST /transaction-recurring/{id}", h.handleUpdateTransactionRecurring())
|
||||||
r.Handle("DELETE /transaction-recurring/{id}", h.handleDeleteTransactionRecurring())
|
r.Handle("DELETE /transaction-recurring/{id}", h.handleDeleteTransactionRecurring())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.HandlerFunc {
|
func (h HandlerImpl) handleTransactionRecurringItemComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,17 +45,17 @@ func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.Hand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
|
func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input := types.TransactionRecurringInput{
|
input := TransactionRecurringInput{
|
||||||
Id: r.PathValue("id"),
|
Id: r.PathValue("id"),
|
||||||
IntervalMonths: r.FormValue("interval-months"),
|
IntervalMonths: r.FormValue("interval-months"),
|
||||||
NextExecution: r.FormValue("next-execution"),
|
NextExecution: r.FormValue("next-execution"),
|
||||||
@@ -72,13 +69,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
|
|||||||
if input.Id == "new" {
|
if input.Id == "new" {
|
||||||
_, err := h.s.Add(r.Context(), user, input)
|
_, err := h.s.Add(r.Context(), user, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_, err := h.s.Update(r.Context(), user, input)
|
_, err := h.s.Update(r.Context(), user, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,13 +84,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
|
func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +100,7 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
|
|||||||
|
|
||||||
err := h.s.Delete(r.Context(), user, id)
|
err := h.s.Delete(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,26 +108,26 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *types.User, id, accountId, treasureChestId string) {
|
func (h HandlerImpl) renderItems(w http.ResponseWriter, r *http.Request, user *auth_types.User, id, accountId, treasureChestId string) {
|
||||||
var transactionsRecurring []*types.TransactionRecurring
|
var transactionsRecurring []*TransactionRecurring
|
||||||
var err error
|
var err error
|
||||||
if accountId == "" && treasureChestId == "" {
|
if accountId == "" && treasureChestId == "" {
|
||||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
|
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
if accountId != "" {
|
if accountId != "" {
|
||||||
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
|
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
|
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := t.TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
|
comp := TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package transaction_recurring
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,8 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,43 +16,43 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TransactionRecurring interface {
|
const (
|
||||||
Add(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
|
DECIMALS_MULTIPLIER = 100
|
||||||
Update(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
|
)
|
||||||
GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error)
|
|
||||||
GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error)
|
|
||||||
GetAllByTreasureChest(ctx context.Context, user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
|
|
||||||
Delete(ctx context.Context, user *types.User, id string) error
|
|
||||||
|
|
||||||
GenerateTransactions(ctx context.Context) error
|
type Service interface {
|
||||||
|
Add(ctx context.Context, user *auth_types.User, transactionRecurring TransactionRecurringInput) (*TransactionRecurring, error)
|
||||||
|
Update(ctx context.Context, user *auth_types.User, transactionRecurring TransactionRecurringInput) (*TransactionRecurring, error)
|
||||||
|
GetAll(ctx context.Context, user *auth_types.User) ([]*TransactionRecurring, error)
|
||||||
|
GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error)
|
||||||
|
GetAllByTreasureChest(ctx context.Context, user *auth_types.User, treasureChestId string) ([]*TransactionRecurring, error)
|
||||||
|
Delete(ctx context.Context, user *auth_types.User, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionRecurringImpl struct {
|
type ServiceImpl struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
clock Clock
|
clock core.Clock
|
||||||
random Random
|
random core.Random
|
||||||
transaction Transaction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transaction Transaction) TransactionRecurring {
|
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||||
return TransactionRecurringImpl{
|
return ServiceImpl{
|
||||||
db: db,
|
db: db,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
random: random,
|
random: random,
|
||||||
transaction: transaction,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) Add(ctx context.Context,
|
func (s ServiceImpl) Add(ctx context.Context,
|
||||||
user *types.User,
|
user *auth_types.User,
|
||||||
transactionRecurringInput types.TransactionRecurringInput,
|
transactionRecurringInput TransactionRecurringInput,
|
||||||
) (*types.TransactionRecurring, error) {
|
) (*TransactionRecurring, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -70,13 +71,13 @@ func (s TransactionRecurringImpl) Add(ctx context.Context,
|
|||||||
VALUES (:id, :user_id, :interval_months,
|
VALUES (:id, :user_id, :interval_months,
|
||||||
:next_execution, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`,
|
:next_execution, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`,
|
||||||
transactionRecurring)
|
transactionRecurring)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,21 +85,21 @@ func (s TransactionRecurringImpl) Add(ctx context.Context,
|
|||||||
return transactionRecurring, nil
|
return transactionRecurring, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) Update(ctx context.Context,
|
func (s ServiceImpl) Update(ctx context.Context,
|
||||||
user *types.User,
|
user *auth_types.User,
|
||||||
input types.TransactionRecurringInput,
|
input TransactionRecurringInput,
|
||||||
) (*types.TransactionRecurring, error) {
|
) (*TransactionRecurring, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(input.Id)
|
uuid, err := uuid.Parse(input.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring update", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring update", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -106,14 +107,14 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
transactionRecurring := &types.TransactionRecurring{}
|
transactionRecurring := &TransactionRecurring{}
|
||||||
err = tx.GetContext(ctx, transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
err = tx.GetContext(ctx, transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, ErrBadRequest)
|
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input)
|
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input)
|
||||||
@@ -135,13 +136,13 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
|||||||
updated_by = :updated_by
|
updated_by = :updated_by
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
AND user_id = :user_id`, transactionRecurring)
|
AND user_id = :user_id`, transactionRecurring)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -149,19 +150,19 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
|||||||
return transactionRecurring, nil
|
return transactionRecurring, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error) {
|
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*TransactionRecurring, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
transactionRecurrings := make([]*TransactionRecurring, 0)
|
||||||
err := s.db.SelectContext(ctx, &transactionRecurrings, `
|
err := s.db.SelectContext(ctx, &transactionRecurrings, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM transaction_recurring
|
FROM transaction_recurring
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
user.Id)
|
user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -169,19 +170,19 @@ func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *types.User)
|
|||||||
return transactionRecurrings, nil
|
return transactionRecurrings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error) {
|
func (s ServiceImpl) GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
accountUuid, err := uuid.Parse(accountId)
|
accountUuid, err := uuid.Parse(accountId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse accountId: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -191,15 +192,15 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *typ
|
|||||||
|
|
||||||
var rowCount int
|
var rowCount int
|
||||||
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id)
|
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("account %v not found: %w", accountId, ErrBadRequest)
|
return nil, fmt.Errorf("account %v not found: %w", accountId, core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
transactionRecurrings := make([]*TransactionRecurring, 0)
|
||||||
err = tx.SelectContext(ctx, &transactionRecurrings, `
|
err = tx.SelectContext(ctx, &transactionRecurrings, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM transaction_recurring
|
FROM transaction_recurring
|
||||||
@@ -207,13 +208,13 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *typ
|
|||||||
AND account_id = ?
|
AND account_id = ?
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
user.Id, accountUuid)
|
user.Id, accountUuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -221,22 +222,22 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *typ
|
|||||||
return transactionRecurrings, nil
|
return transactionRecurrings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
func (s ServiceImpl) GetAllByTreasureChest(ctx context.Context,
|
||||||
user *types.User,
|
user *auth_types.User,
|
||||||
treasureChestId string,
|
treasureChestId string,
|
||||||
) ([]*types.TransactionRecurring, error) {
|
) ([]*TransactionRecurring, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChestUuid, err := uuid.Parse(treasureChestId)
|
treasureChestUuid, err := uuid.Parse(treasureChestId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse treasureChestId: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -246,15 +247,15 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
|||||||
|
|
||||||
var rowCount int
|
var rowCount int
|
||||||
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id)
|
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, ErrBadRequest)
|
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
transactionRecurrings := make([]*TransactionRecurring, 0)
|
||||||
err = tx.SelectContext(ctx, &transactionRecurrings, `
|
err = tx.SelectContext(ctx, &transactionRecurrings, `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM transaction_recurring
|
FROM transaction_recurring
|
||||||
@@ -262,13 +263,13 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
|||||||
AND treasure_chest_id = ?
|
AND treasure_chest_id = ?
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
user.Id, treasureChestUuid)
|
user.Id, treasureChestUuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -276,18 +277,18 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
|||||||
return transactionRecurrings, nil
|
return transactionRecurrings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User, id string) error {
|
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ErrUnauthorized
|
return core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring delete", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring delete", "err", err)
|
||||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -295,21 +296,21 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User,
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var transactionRecurring types.TransactionRecurring
|
var transactionRecurring TransactionRecurring
|
||||||
err = tx.GetContext(ctx, &transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
err = tx.GetContext(ctx, &transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := tx.ExecContext(ctx, "DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
|
r, err := tx.ExecContext(ctx, "DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", r, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -317,69 +318,13 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) GenerateTransactions(ctx context.Context) error {
|
func (s ServiceImpl) validateAndEnrichTransactionRecurring(
|
||||||
now := s.clock.Now()
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
recurringTransactions := make([]*types.TransactionRecurring, 0)
|
|
||||||
err = tx.SelectContext(ctx, &recurringTransactions, `
|
|
||||||
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
|
|
||||||
now)
|
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, transactionRecurring := range recurringTransactions {
|
|
||||||
user := &types.User{
|
|
||||||
Id: transactionRecurring.UserId,
|
|
||||||
}
|
|
||||||
transaction := types.Transaction{
|
|
||||||
Timestamp: *transactionRecurring.NextExecution,
|
|
||||||
Party: transactionRecurring.Party,
|
|
||||||
Description: transactionRecurring.Description,
|
|
||||||
|
|
||||||
TreasureChestId: transactionRecurring.TreasureChestId,
|
|
||||||
Value: transactionRecurring.Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.transaction.Add(ctx, tx, user, transaction)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
|
|
||||||
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
|
|
||||||
nextExecution, transactionRecurring.Id, user.Id)
|
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", r, err)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit()
|
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx *sqlx.Tx,
|
tx *sqlx.Tx,
|
||||||
oldTransactionRecurring *types.TransactionRecurring,
|
oldTransactionRecurring *TransactionRecurring,
|
||||||
userId uuid.UUID,
|
userId uuid.UUID,
|
||||||
input types.TransactionRecurringInput,
|
input TransactionRecurringInput,
|
||||||
) (*types.TransactionRecurring, error) {
|
) (*TransactionRecurring, error) {
|
||||||
var (
|
var (
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
accountUuid *uuid.UUID
|
accountUuid *uuid.UUID
|
||||||
@@ -397,7 +342,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
|||||||
if oldTransactionRecurring == nil {
|
if oldTransactionRecurring == nil {
|
||||||
id, err = s.random.UUID(ctx)
|
id, err = s.random.UUID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
createdAt = s.clock.Now()
|
createdAt = s.clock.Now()
|
||||||
createdBy = userId
|
createdBy = userId
|
||||||
@@ -416,17 +361,17 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
|||||||
temp, err := uuid.Parse(input.AccountId)
|
temp, err := uuid.Parse(input.AccountId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse accountId: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
accountUuid = &temp
|
accountUuid = &temp
|
||||||
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
|
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if rowCount == 0 {
|
if rowCount == 0 {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
|
return nil, fmt.Errorf("account not found: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAccount = true
|
hasAccount = true
|
||||||
@@ -436,48 +381,48 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
|||||||
temp, err := uuid.Parse(input.TreasureChestId)
|
temp, err := uuid.Parse(input.TreasureChestId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse treasureChestId: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
treasureChestUuid = &temp
|
treasureChestUuid = &temp
|
||||||
var treasureChest types.TreasureChest
|
var treasureChest treasure_chest_types.TreasureChest
|
||||||
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
|
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
|
||||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
|
err = core.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
|
return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if treasureChest.ParentId == nil {
|
if treasureChest.ParentId == nil {
|
||||||
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest)
|
return nil, fmt.Errorf("treasure chest is a group: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
hasTreasureChest = true
|
hasTreasureChest = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasAccount && !hasTreasureChest {
|
if !hasAccount && !hasTreasureChest {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest)
|
return nil, fmt.Errorf("either account or treasure chest is required: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
if hasAccount && hasTreasureChest {
|
if hasAccount && hasTreasureChest {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", ErrBadRequest)
|
return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
valueFloat, err := strconv.ParseFloat(input.Value, 64)
|
valueFloat, err := strconv.ParseFloat(input.Value, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse value: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
|
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
|
||||||
|
|
||||||
if input.Party != "" {
|
if input.Party != "" {
|
||||||
err = validateString(input.Party, "party")
|
err = core.ValidateString(input.Party, "party")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if input.Description != "" {
|
if input.Description != "" {
|
||||||
err = validateString(input.Description, "description")
|
err = core.ValidateString(input.Description, "description")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -485,25 +430,25 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
|||||||
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
|
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse intervalMonths: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
if intervalMonths < 1 {
|
if intervalMonths < 1 {
|
||||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||||
return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", ErrBadRequest)
|
return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
var nextExecution *time.Time = nil
|
var nextExecution *time.Time = nil
|
||||||
if input.NextExecution != "" {
|
if input.NextExecution != "" {
|
||||||
t, err := time.Parse("2006-01-02", input.NextExecution)
|
t, err := time.Parse("2006-01-02", input.NextExecution)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "transaction validate", "err", err)
|
slog.ErrorContext(ctx, "transaction validate", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse timestamp: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
||||||
nextExecution = &t
|
nextExecution = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionRecurring := types.TransactionRecurring{
|
transactionRecurring := TransactionRecurring{
|
||||||
Id: id,
|
Id: id,
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package transaction_recurring
|
package transaction_recurring
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
import "time"
|
"fmt"
|
||||||
import "spend-sparrow/internal/template/svg"
|
"spend-sparrow/internal/core"
|
||||||
import "spend-sparrow/internal/types"
|
"spend-sparrow/internal/template/svg"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
templ TransactionRecurringItems(transactionsRecurring []*types.TransactionRecurring, editId, accountId, treasureChestId string) {
|
templ TransactionRecurringItems(transactionsRecurring []*TransactionRecurring, editId, accountId, treasureChestId string) {
|
||||||
<!-- Don't use table, because embedded forms are only valid for cells -->
|
<!-- Don't use table, because embedded forms are only valid for cells -->
|
||||||
<div id="transaction-recurring" class="max-w-full grid gap-4 mt-10 grid-cols-[max-content_auto_auto_auto_auto_max-content] items-center text-xl">
|
<div id="transaction-recurring" class="max-w-full grid gap-4 mt-10 grid-cols-[max-content_auto_auto_auto_auto_max-content] items-center text-xl">
|
||||||
<span class="text-sm text-gray-500">Next Execution</span>
|
<span class="text-sm text-gray-500">Next Execution</span>
|
||||||
@@ -27,7 +29,7 @@ templ TransactionRecurringItems(transactionsRecurring []*types.TransactionRecurr
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
|
templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) {
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
if transactionRecurring.NextExecution != nil {
|
if transactionRecurring.NextExecution != nil {
|
||||||
{ transactionRecurring.NextExecution.Format("2006/01") }
|
{ transactionRecurring.NextExecution.Format("2006/01") }
|
||||||
@@ -53,9 +55,9 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
|
|||||||
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
|
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
|
||||||
</p>
|
</p>
|
||||||
if transactionRecurring.Value < 0 {
|
if transactionRecurring.Value < 0 {
|
||||||
<p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
|
<p class="text-right text-red-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
|
||||||
} else {
|
} else {
|
||||||
<p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
|
<p class="text-right text-green-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
|
||||||
}
|
}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -84,7 +86,7 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
|
templ EditTransactionRecurring(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) {
|
||||||
{{
|
{{
|
||||||
var (
|
var (
|
||||||
id string
|
id string
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package transaction_recurring
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -1,131 +1,128 @@
|
|||||||
package handler
|
package treasure_chest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/internal/handler/middleware"
|
"spend-sparrow/internal/core"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/transaction_recurring"
|
||||||
tr "spend-sparrow/internal/template/transaction_recurring"
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
t "spend-sparrow/internal/template/treasurechest"
|
|
||||||
"spend-sparrow/internal/types"
|
|
||||||
"spend-sparrow/internal/utils"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TreasureChest interface {
|
type Handler interface {
|
||||||
Handle(router *http.ServeMux)
|
Handle(router *http.ServeMux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TreasureChestImpl struct {
|
type HandlerImpl struct {
|
||||||
s service.TreasureChest
|
s Service
|
||||||
transactionRecurring service.TransactionRecurring
|
transactionRecurring transaction_recurring.Service
|
||||||
r *Render
|
r *core.Render
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTreasureChest(s service.TreasureChest, transactionRecurring service.TransactionRecurring, r *Render) TreasureChest {
|
func NewHandler(s Service, transactionRecurring transaction_recurring.Service, r *core.Render) Handler {
|
||||||
return TreasureChestImpl{
|
return HandlerImpl{
|
||||||
s: s,
|
s: s,
|
||||||
transactionRecurring: transactionRecurring,
|
transactionRecurring: transactionRecurring,
|
||||||
r: r,
|
r: r,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TreasureChestImpl) Handle(r *http.ServeMux) {
|
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||||
r.Handle("GET /treasurechest", h.handleTreasureChestPage())
|
r.Handle("GET /treasurechest", h.handleHandlerPage())
|
||||||
r.Handle("GET /treasurechest/{id}", h.handleTreasureChestItemComp())
|
r.Handle("GET /treasurechest/{id}", h.handleHandlerItemComp())
|
||||||
r.Handle("POST /treasurechest/{id}", h.handleUpdateTreasureChest())
|
r.Handle("POST /treasurechest/{id}", h.handleUpdateHandler())
|
||||||
r.Handle("DELETE /treasurechest/{id}", h.handleDeleteTreasureChest())
|
r.Handle("DELETE /treasurechest/{id}", h.handleDeleteHandler())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
|
func (h HandlerImpl) handleHandlerPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests, err := h.s.GetAll(r.Context(), user)
|
treasureChests, err := h.s.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
|
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
||||||
|
|
||||||
comp := t.TreasureChest(treasureChests, monthlySums)
|
comp := TreasureChestComp(treasureChests, monthlySums)
|
||||||
h.r.RenderLayout(r, w, comp, user)
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
|
func (h HandlerImpl) handleHandlerItemComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests, err := h.s.GetAll(r.Context(), user)
|
treasureChests, err := h.s.GetAll(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if id == "new" {
|
if id == "new" {
|
||||||
comp := t.EditTreasureChest(nil, treasureChests, nil)
|
comp := EditTreasureChest(nil, treasureChests, nil)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChest, err := h.s.Get(r.Context(), user, id)
|
treasureChest, err := h.s.Get(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
|
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
transactionsRec := tr.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
|
transactionsRec := transaction_recurring.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
|
||||||
|
|
||||||
var comp templ.Component
|
var comp templ.Component
|
||||||
if r.URL.Query().Get("edit") == "true" {
|
if r.URL.Query().Get("edit") == "true" {
|
||||||
comp = t.EditTreasureChest(treasureChest, treasureChests, transactionsRec)
|
comp = EditTreasureChest(treasureChest, treasureChests, transactionsRec)
|
||||||
} else {
|
} else {
|
||||||
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
||||||
comp = t.TreasureChestItem(treasureChest, monthlySums)
|
comp = TreasureChestItem(treasureChest, monthlySums)
|
||||||
}
|
}
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
|
func (h HandlerImpl) handleUpdateHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
treasureChest *types.TreasureChest
|
treasureChest *treasure_chest_types.TreasureChest
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
@@ -134,38 +131,38 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
|
|||||||
if id == "new" {
|
if id == "new" {
|
||||||
treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
|
treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
|
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
|
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests := make([]*types.TreasureChest, 1)
|
treasureChests := make([]*treasure_chest_types.TreasureChest, 1)
|
||||||
treasureChests[0] = treasureChest
|
treasureChests[0] = treasureChest
|
||||||
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
||||||
comp := t.TreasureChestItem(treasureChest, monthlySums)
|
comp := TreasureChestItem(treasureChest, monthlySums)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
|
func (h HandlerImpl) handleDeleteHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
core.UpdateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := core.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
core.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,15 +170,15 @@ func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
|
|||||||
|
|
||||||
err := h.s.Delete(r.Context(), user, id)
|
err := h.s.Delete(r.Context(), user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
core.HandleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h TreasureChestImpl) calculateMonthlySums(
|
func (h HandlerImpl) calculateMonthlySums(
|
||||||
treasureChests []*types.TreasureChest,
|
treasureChests []*treasure_chest_types.TreasureChest,
|
||||||
transactionsRecurring []*types.TransactionRecurring,
|
transactionsRecurring []*transaction_recurring.TransactionRecurring,
|
||||||
) map[uuid.UUID]int64 {
|
) map[uuid.UUID]int64 {
|
||||||
monthlySums := make(map[uuid.UUID]int64)
|
monthlySums := make(map[uuid.UUID]int64)
|
||||||
for _, tc := range treasureChests {
|
for _, tc := range treasureChests {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package treasure_chest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,46 +6,47 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TreasureChest interface {
|
type Service interface {
|
||||||
Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error)
|
Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error)
|
||||||
Update(ctx context.Context, user *types.User, id, parentId, name string) (*types.TreasureChest, error)
|
Update(ctx context.Context, user *auth_types.User, id, parentId, name string) (*treasure_chest_types.TreasureChest, error)
|
||||||
Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error)
|
Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error)
|
||||||
GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error)
|
GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error)
|
||||||
Delete(ctx context.Context, user *types.User, id string) error
|
Delete(ctx context.Context, user *auth_types.User, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type TreasureChestImpl struct {
|
type ServiceImpl struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
clock Clock
|
clock core.Clock
|
||||||
random Random
|
random core.Random
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTreasureChest(db *sqlx.DB, random Random, clock Clock) TreasureChest {
|
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||||
return TreasureChestImpl{
|
return ServiceImpl{
|
||||||
db: db,
|
db: db,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
random: random,
|
random: random,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error) {
|
func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
newId, err := s.random.UUID(ctx)
|
newId, err := s.random.UUID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateString(name, "name")
|
err = core.ValidateString(name, "name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -57,12 +58,12 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if parent.ParentId != nil {
|
if parent.ParentId != nil {
|
||||||
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
|
return nil, fmt.Errorf("only a depth of 1 allowed: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
parentUuid = &parent.Id
|
parentUuid = &parent.Id
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChest := &types.TreasureChest{
|
treasureChest := &treasure_chest_types.TreasureChest{
|
||||||
Id: newId,
|
Id: newId,
|
||||||
ParentId: parentUuid,
|
ParentId: parentUuid,
|
||||||
UserId: user.Id,
|
UserId: user.Id,
|
||||||
@@ -80,7 +81,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId,
|
|||||||
r, err := s.db.NamedExecContext(ctx, `
|
r, err := s.db.NamedExecContext(ctx, `
|
||||||
INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by)
|
INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by)
|
||||||
VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest)
|
VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,22 +89,22 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId,
|
|||||||
return treasureChest, nil
|
return treasureChest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
|
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, idStr, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
err := validateString(name, "name")
|
err := core.ValidateString(name, "name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, err := uuid.Parse(idStr)
|
id, err := uuid.Parse(idStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "treasureChest update", "err", err)
|
slog.ErrorContext(ctx, "treasureChest update", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -111,14 +112,14 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
treasureChest := &types.TreasureChest{}
|
treasureChest := &treasure_chest_types.TreasureChest{}
|
||||||
err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
|
err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
|
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
var parentUuid *uuid.UUID
|
var parentUuid *uuid.UUID
|
||||||
@@ -129,12 +130,12 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
|
|||||||
}
|
}
|
||||||
var childCount int
|
var childCount int
|
||||||
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
|
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if parent.ParentId != nil || childCount > 0 {
|
if parent.ParentId != nil || childCount > 0 {
|
||||||
return nil, fmt.Errorf("only one level allowed: %w", ErrBadRequest)
|
return nil, fmt.Errorf("only one level allowed: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
parentUuid = &parent.Id
|
parentUuid = &parent.Id
|
||||||
@@ -156,13 +157,13 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
|
|||||||
updated_by = :updated_by
|
updated_by = :updated_by
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
AND user_id = :user_id`, treasureChest)
|
AND user_id = :user_id`, treasureChest)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -170,56 +171,56 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
|
|||||||
return treasureChest, nil
|
return treasureChest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TreasureChestImpl) Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error) {
|
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uuid, err := uuid.Parse(id)
|
uuid, err := uuid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "treasureChest get", "err", err)
|
slog.ErrorContext(ctx, "treasureChest get", "err", err)
|
||||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
var treasureChest types.TreasureChest
|
var treasureChest treasure_chest_types.TreasureChest
|
||||||
err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, core.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
|
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil, types.ErrInternal
|
return nil, core.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return &treasureChest, nil
|
return &treasureChest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TreasureChestImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error) {
|
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
treasureChests := make([]*types.TreasureChest, 0)
|
treasureChests := make([]*treasure_chest_types.TreasureChest, 0)
|
||||||
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
|
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortTreasureChests(treasureChests), nil
|
return SortTreasureChests(treasureChests), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr string) error {
|
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, idStr string) error {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ErrUnauthorized
|
return core.ErrUnauthorized
|
||||||
}
|
}
|
||||||
id, err := uuid.Parse(idStr)
|
id, err := uuid.Parse(idStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "treasureChest delete", "err", err)
|
slog.ErrorContext(ctx, "treasureChest delete", "err", err)
|
||||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -229,47 +230,47 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr s
|
|||||||
|
|
||||||
childCount := 0
|
childCount := 0
|
||||||
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
|
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if childCount > 0 {
|
if childCount > 0 {
|
||||||
return fmt.Errorf("treasure chest has children: %w", ErrBadRequest)
|
return fmt.Errorf("treasure chest has children: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionsCount := 0
|
transactionsCount := 0
|
||||||
err = tx.GetContext(ctx, &transactionsCount,
|
err = tx.GetContext(ctx, &transactionsCount,
|
||||||
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
|
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
|
||||||
user.Id, id)
|
user.Id, id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if transactionsCount > 0 {
|
if transactionsCount > 0 {
|
||||||
return fmt.Errorf("treasure chest has transactions: %w", ErrBadRequest)
|
return fmt.Errorf("treasure chest has transactions: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
recurringCount := 0
|
recurringCount := 0
|
||||||
err = tx.GetContext(ctx, &recurringCount, `
|
err = tx.GetContext(ctx, &recurringCount, `
|
||||||
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
|
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
|
||||||
user.Id, id)
|
user.Id, id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if recurringCount > 0 {
|
if recurringCount > 0 {
|
||||||
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", ErrBadRequest)
|
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", core.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
|
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -277,12 +278,12 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
func SortTreasureChests(nodes []*treasure_chest_types.TreasureChest) []*treasure_chest_types.TreasureChest {
|
||||||
var (
|
var (
|
||||||
roots []*types.TreasureChest
|
roots []*treasure_chest_types.TreasureChest
|
||||||
)
|
)
|
||||||
children := make(map[uuid.UUID][]*types.TreasureChest)
|
children := make(map[uuid.UUID][]*treasure_chest_types.TreasureChest)
|
||||||
result := make([]*types.TreasureChest, 0)
|
result := make([]*treasure_chest_types.TreasureChest, 0)
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
if node.ParentId == nil {
|
if node.ParentId == nil {
|
||||||
@@ -292,7 +293,7 @@ func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(roots, func(a, b *types.TreasureChest) int {
|
slices.SortFunc(roots, func(a, b *treasure_chest_types.TreasureChest) int {
|
||||||
return compareStrings(a.Name, b.Name)
|
return compareStrings(a.Name, b.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,7 +302,7 @@ func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
|||||||
|
|
||||||
childList := children[root.Id]
|
childList := children[root.Id]
|
||||||
|
|
||||||
slices.SortFunc(childList, func(a, b *types.TreasureChest) int {
|
slices.SortFunc(childList, func(a, b *treasure_chest_types.TreasureChest) int {
|
||||||
return compareStrings(a.Name, b.Name)
|
return compareStrings(a.Name, b.Name)
|
||||||
})
|
})
|
||||||
result = append(result, childList...)
|
result = append(result, childList...)
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package treasurechest
|
package treasure_chest
|
||||||
|
|
||||||
import "spend-sparrow/internal/template/svg"
|
import (
|
||||||
import "spend-sparrow/internal/types"
|
"github.com/google/uuid"
|
||||||
import "github.com/google/uuid"
|
"spend-sparrow/internal/core"
|
||||||
|
"spend-sparrow/internal/template/svg"
|
||||||
|
"spend-sparrow/internal/treasure_chest_types"
|
||||||
|
)
|
||||||
|
|
||||||
templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.UUID]int64) {
|
templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
|
||||||
<div class="max-w-6xl mt-10 mx-auto">
|
<div class="max-w-6xl mt-10 mx-auto">
|
||||||
<button
|
<button
|
||||||
hx-get="/treasurechest/new"
|
hx-get="/treasurechest/new"
|
||||||
@@ -12,7 +15,9 @@ templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.
|
|||||||
hx-swap="afterbegin"
|
hx-swap="afterbegin"
|
||||||
class="ml-auto text-center button button-primary px-2 flex items-center gap-2"
|
class="ml-auto text-center button button-primary px-2 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@svg.Plus()
|
<div class="w-3">
|
||||||
|
@svg.Plus()
|
||||||
|
</div>
|
||||||
New Treasure Chest
|
New Treasure Chest
|
||||||
</button>
|
</button>
|
||||||
<div id="treasurechest-items" class="my-6 flex flex-col">
|
<div id="treasurechest-items" class="my-6 flex flex-col">
|
||||||
@@ -23,30 +28,30 @@ templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest, transactionsRecurring templ.Component) {
|
templ EditTreasureChest(treasureChest *treasure_chest_types.TreasureChest, parents []*treasure_chest_types.TreasureChest, transactionsRecurring templ.Component) {
|
||||||
{{
|
{{
|
||||||
var (
|
var (
|
||||||
id string
|
id string
|
||||||
name string
|
name string
|
||||||
parentId uuid.UUID
|
parentId uuid.UUID
|
||||||
cancelUrl string
|
cancelUrl string
|
||||||
)
|
)
|
||||||
|
|
||||||
indentation := " mt-10"
|
indentation := " mt-10"
|
||||||
if treasureChest == nil {
|
if treasureChest == nil {
|
||||||
id = "new"
|
id = "new"
|
||||||
name = ""
|
name = ""
|
||||||
parentId = uuid.Nil
|
parentId = uuid.Nil
|
||||||
cancelUrl = "/empty"
|
cancelUrl = "/empty"
|
||||||
} else {
|
} else {
|
||||||
id = treasureChest.Id.String()
|
id = treasureChest.Id.String()
|
||||||
name = treasureChest.Name
|
name = treasureChest.Name
|
||||||
if treasureChest.ParentId != nil {
|
if treasureChest.ParentId != nil {
|
||||||
parentId = *treasureChest.ParentId
|
parentId = *treasureChest.ParentId
|
||||||
indentation = " mt-2 ml-14"
|
indentation = " mt-2 ml-14"
|
||||||
|
}
|
||||||
|
cancelUrl = "/treasurechest/" + id
|
||||||
}
|
}
|
||||||
cancelUrl = "/treasurechest/" + id
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
<div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
|
<div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
|
||||||
<form
|
<form
|
||||||
@@ -106,7 +111,9 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
|
|||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="button button-primary ml-auto px-2 flex items-center gap-2"
|
class="button button-primary ml-auto px-2 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@svg.Plus()
|
<div class="w-3">
|
||||||
|
@svg.Plus()
|
||||||
|
</div>
|
||||||
<p>New Monthly Transaction</p>
|
<p>New Monthly Transaction</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,30 +122,30 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid.UUID]int64) {
|
templ TreasureChestItem(treasureChest *treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
|
||||||
{{
|
{{
|
||||||
var indentation string
|
var indentation string
|
||||||
viewTransactions := ""
|
viewTransactions := ""
|
||||||
if treasureChest.ParentId != nil {
|
if treasureChest.ParentId != nil {
|
||||||
indentation = " mt-2 ml-14"
|
indentation = " mt-2 ml-14"
|
||||||
} else {
|
} else {
|
||||||
indentation = " mt-10"
|
indentation = " mt-10"
|
||||||
viewTransactions = "hidden"
|
viewTransactions = "hidden"
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
|
<div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
|
||||||
<div class="text-xl flex justify-end items-center gap-4">
|
<div class="text-xl flex justify-end items-center gap-4">
|
||||||
<p class="mr-auto">{ treasureChest.Name }</p>
|
<p class="mr-auto">{ treasureChest.Name }</p>
|
||||||
<p class="mr-20 text-gray-600">
|
<p class="mr-20 text-gray-600">
|
||||||
if treasureChest.ParentId != nil {
|
if treasureChest.ParentId != nil {
|
||||||
+ { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm"> per month</span>
|
+ { core.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm"> per month</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
if treasureChest.ParentId != nil {
|
if treasureChest.ParentId != nil {
|
||||||
if treasureChest.CurrentBalance < 0 {
|
if treasureChest.CurrentBalance < 0 {
|
||||||
<p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
|
<p class="mr-20 min-w-20 text-right text-red-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p>
|
||||||
} else {
|
} else {
|
||||||
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
|
<p class="mr-20 min-w-20 text-right text-green-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<a
|
<a
|
||||||
@@ -177,8 +184,8 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.TreasureChest {
|
func filterNoChildNoSelf(nodes []*treasure_chest_types.TreasureChest, selfId string) []*treasure_chest_types.TreasureChest {
|
||||||
var result []*types.TreasureChest
|
var result []*treasure_chest_types.TreasureChest
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
if node.ParentId == nil && node.Id.String() != selfId {
|
if node.ParentId == nil && node.Id.String() != selfId {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package types
|
package treasure_chest_types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrInternal = errors.New("internal server error")
|
|
||||||
ErrUnauthorized = errors.New("you are not authorized to perform this action")
|
|
||||||
)
|
|
||||||
7
main.go
7
main.go
@@ -22,8 +22,11 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
|
db, err := otelsqlx.Open(
|
||||||
otelsql.WithAttributes(semconv.DBSystemSqlite))
|
"sqlite3",
|
||||||
|
"./data/spend-sparrow.db?_journal_mode=WAL",
|
||||||
|
otelsql.WithAttributes(semconv.DBSystemSqlite),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
|
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
|
||||||
return
|
return
|
||||||
|
|||||||
14
migration/010_budget.up.sql
Normal file
14
migration/010_budget.up.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
CREATE TABLE "budget" (
|
||||||
|
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_at DATETIME,
|
||||||
|
updated_by TEXT
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
1
migration/011_budget_rename_description.up.sql
Normal file
1
migration/011_budget_rename_description.up.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "budget" RENAME COLUMN description TO name;
|
||||||
13
migration/012_tag.up.sql
Normal file
13
migration/012_tag.up.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
CREATE TABLE "tag" (
|
||||||
|
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_at DATETIME,
|
||||||
|
updated_by TEXT
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
417
package-lock.json
generated
417
package-lock.json
generated
@@ -9,23 +9,10 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "4.1.12",
|
"@tailwindcss/cli": "4.1.18",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
"htmx.org": "2.0.6",
|
"htmx.org": "2.0.8",
|
||||||
"tailwindcss": "4.1.12"
|
"tailwindcss": "4.1.18"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@isaacs/fs-minipass": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"minipass": "^7.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
@@ -61,9 +48,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -388,73 +375,68 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/cli": {
|
"node_modules/@tailwindcss/cli": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz",
|
||||||
"integrity": "sha512-2PyJ5MGh/6JPS+cEaAq6MGDx3UemkX/mJt+/phm7/VOpycpecwNnHuFZbbgx6TNK/aIjvFOhhTVlappM7tmqvQ==",
|
"integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parcel/watcher": "^2.5.1",
|
"@parcel/watcher": "^2.5.1",
|
||||||
"@tailwindcss/node": "4.1.12",
|
"@tailwindcss/node": "4.1.18",
|
||||||
"@tailwindcss/oxide": "4.1.12",
|
"@tailwindcss/oxide": "4.1.18",
|
||||||
"enhanced-resolve": "^5.18.3",
|
"enhanced-resolve": "^5.18.3",
|
||||||
"mri": "^1.2.0",
|
"mri": "^1.2.0",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"tailwindcss": "4.1.12"
|
"tailwindcss": "4.1.18"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"tailwindcss": "dist/index.mjs"
|
"tailwindcss": "dist/index.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||||
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
|
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"enhanced-resolve": "^5.18.3",
|
"enhanced-resolve": "^5.18.3",
|
||||||
"jiti": "^2.5.1",
|
"jiti": "^2.6.1",
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.2",
|
||||||
"magic-string": "^0.30.17",
|
"magic-string": "^0.30.21",
|
||||||
"source-map-js": "^1.2.1",
|
"source-map-js": "^1.2.1",
|
||||||
"tailwindcss": "4.1.12"
|
"tailwindcss": "4.1.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide": {
|
"node_modules/@tailwindcss/oxide": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||||
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
|
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.4",
|
|
||||||
"tar": "^7.4.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-android-arm64": "4.1.12",
|
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
|
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.1.12",
|
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
|
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
|
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
|
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
|
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
|
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
|
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||||
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
|
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -469,9 +451,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||||
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
|
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -486,9 +468,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||||
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
|
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -503,9 +485,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||||
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
|
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -520,9 +502,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||||
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
|
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -537,9 +519,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||||
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
|
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -554,9 +536,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||||
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
|
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -571,9 +553,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||||
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
|
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -588,9 +570,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||||
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
|
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -605,9 +587,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||||
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
|
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@napi-rs/wasm-runtime",
|
"@napi-rs/wasm-runtime",
|
||||||
"@emnapi/core",
|
"@emnapi/core",
|
||||||
@@ -623,30 +605,30 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "^1.4.5",
|
"@emnapi/core": "^1.7.1",
|
||||||
"@emnapi/runtime": "^1.4.5",
|
"@emnapi/runtime": "^1.7.1",
|
||||||
"@emnapi/wasi-threads": "^1.0.4",
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
"@napi-rs/wasm-runtime": "^0.2.12",
|
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||||
"@tybys/wasm-util": "^0.10.0",
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
"version": "1.4.5",
|
"version": "1.7.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.0.4",
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
"version": "1.4.5",
|
"version": "1.7.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -656,7 +638,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.0.4",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -666,19 +648,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "^1.4.3",
|
"@emnapi/core": "^1.7.1",
|
||||||
"@emnapi/runtime": "^1.4.3",
|
"@emnapi/runtime": "^1.7.1",
|
||||||
"@tybys/wasm-util": "^0.10.0"
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -688,16 +670,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||||
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
|
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -712,9 +694,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||||
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
|
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -728,16 +710,6 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide/node_modules/detect-libc": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
@@ -751,16 +723,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chownr": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
@@ -820,9 +782,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/htmx.org": {
|
"node_modules/htmx.org": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
|
||||||
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==",
|
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
@@ -860,9 +822,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.5.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -870,9 +832,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -886,22 +848,44 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"lightningcss-darwin-arm64": "1.30.1",
|
"lightningcss-android-arm64": "1.30.2",
|
||||||
"lightningcss-darwin-x64": "1.30.1",
|
"lightningcss-darwin-arm64": "1.30.2",
|
||||||
"lightningcss-freebsd-x64": "1.30.1",
|
"lightningcss-darwin-x64": "1.30.2",
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.30.1",
|
"lightningcss-freebsd-x64": "1.30.2",
|
||||||
"lightningcss-linux-arm64-gnu": "1.30.1",
|
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||||
"lightningcss-linux-arm64-musl": "1.30.1",
|
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||||
"lightningcss-linux-x64-gnu": "1.30.1",
|
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||||
"lightningcss-linux-x64-musl": "1.30.1",
|
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||||
"lightningcss-win32-arm64-msvc": "1.30.1",
|
"lightningcss-linux-x64-musl": "1.30.2",
|
||||||
"lightningcss-win32-x64-msvc": "1.30.1"
|
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||||
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
|
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -920,9 +904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||||
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
|
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -941,9 +925,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||||
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
|
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -962,9 +946,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||||
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
|
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -983,9 +967,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||||
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
|
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1004,9 +988,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||||
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
|
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1025,9 +1009,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1046,9 +1030,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||||
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
|
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1067,9 +1051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||||
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
|
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1088,9 +1072,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||||
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
|
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1109,9 +1093,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss/node_modules/detect-libc": {
|
"node_modules/lightningcss/node_modules/detect-libc": {
|
||||||
"version": "2.0.4",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1119,13 +1103,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
@@ -1142,45 +1126,6 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minipass": {
|
|
||||||
"version": "7.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
|
||||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minizlib": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"minipass": "^7.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mkdirp": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"mkdirp": "dist/cjs/src/bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
@@ -1229,9 +1174,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1245,24 +1190,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
|
||||||
"version": "7.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
|
|
||||||
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/fs-minipass": "^4.0.0",
|
|
||||||
"chownr": "^3.0.0",
|
|
||||||
"minipass": "^7.1.2",
|
|
||||||
"minizlib": "^3.0.1",
|
|
||||||
"mkdirp": "^3.0.1",
|
|
||||||
"yallist": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -1283,16 +1210,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zrender": {
|
"node_modules/zrender": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "4.1.12",
|
"@tailwindcss/cli": "4.1.18",
|
||||||
"htmx.org": "2.0.6",
|
"htmx.org": "2.0.8",
|
||||||
"tailwindcss": "4.1.12",
|
"tailwindcss": "4.1.18",
|
||||||
"echarts": "6.0.0"
|
"echarts": "6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
menuButton.addEventListener("click", function (e) {
|
menuButton.addEventListener("click", function() {
|
||||||
menu.showModal();
|
menu.showModal();
|
||||||
});
|
});
|
||||||
menuButtonClose.addEventListener("click", function (e) {
|
menuButtonClose.addEventListener("click", function() {
|
||||||
menu.close();
|
menu.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function updateTime() {
|
|||||||
document.querySelectorAll(".datetime").forEach((el) => {
|
document.querySelectorAll(".datetime").forEach((el) => {
|
||||||
if (el.textContent !== "") {
|
if (el.textContent !== "") {
|
||||||
el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) : el.textContent;
|
el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) : el.textContent;
|
||||||
} else if (el.attributes['value'] !== "") {
|
} else if (el.attributes['value'] && el.attributes['value'] !== "") {
|
||||||
const value = el.attributes['value'].value;
|
const value = el.attributes['value'].value;
|
||||||
const newDate = value.includes("UTC") ? new Date(value) : value;
|
const newDate = value.includes("UTC") ? new Date(value) : value;
|
||||||
el.valueAsDate = newDate;
|
el.valueAsDate = newDate;
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
if (!page || !page1 || !pagePrev1 || !pageNext1 || !page2 || !pagePrev2 || !pageNext2 || !transactionFilterForm) {
|
if (typeof page === "undefined" ||
|
||||||
|
typeof page1 === "undefined" ||
|
||||||
|
typeof pagePrev1 === "undefined" ||
|
||||||
|
typeof pageNext1 === "undefined" ||
|
||||||
|
typeof page2 === "undefined" ||
|
||||||
|
typeof pagePrev2 === "undefined" ||
|
||||||
|
typeof pageNext2 === "undefined" ||
|
||||||
|
typeof transactionFilterForm === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const scrollToTop = function() {
|
const scrollToTop = function() {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package test_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/authentication"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ func setupDb(t *testing.T) *sqlx.DB {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
err = db.RunMigrations(context.Background(), d, "../")
|
err = core.RunMigrations(context.Background(), d, "../")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Error running migrations: %v", err)
|
t.Fatalf("Error running migrations: %v", err)
|
||||||
}
|
}
|
||||||
@@ -42,11 +43,11 @@ func TestUser(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
expected := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
expected := auth_types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
||||||
|
|
||||||
err := underTest.InsertUser(context.Background(), expected)
|
err := underTest.InsertUser(context.Background(), expected)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -63,38 +64,38 @@ func TestUser(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
_, err := underTest.GetUserByEmail(context.Background(), "nonExistentEmail")
|
_, err := underTest.GetUserByEmail(context.Background(), "nonExistentEmail")
|
||||||
assert.Equal(t, db.ErrNotFound, err)
|
assert.Equal(t, core.ErrNotFound, err)
|
||||||
})
|
})
|
||||||
t.Run("should return ErrUserExist", func(t *testing.T) {
|
t.Run("should return ErrUserExist", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
user := auth_types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
||||||
|
|
||||||
err := underTest.InsertUser(context.Background(), user)
|
err := underTest.InsertUser(context.Background(), user)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = underTest.InsertUser(context.Background(), user)
|
err = underTest.InsertUser(context.Background(), user)
|
||||||
assert.Equal(t, db.ErrAlreadyExists, err)
|
assert.Equal(t, core.ErrAlreadyExists, err)
|
||||||
})
|
})
|
||||||
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
|
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
|
user := auth_types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
|
||||||
|
|
||||||
err := underTest.InsertUser(context.Background(), user)
|
err := underTest.InsertUser(context.Background(), user)
|
||||||
assert.Equal(t, types.ErrInternal, err)
|
assert.Equal(t, core.ErrInternal, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,11 +106,11 @@ func TestToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
expiresAt := createAt.Add(24 * time.Hour)
|
expiresAt := createAt.Add(24 * time.Hour)
|
||||||
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt)
|
expected := auth_types.NewToken(uuid.New(), "sessionId", "token", auth_types.TokenTypeCsrf, createAt, expiresAt)
|
||||||
|
|
||||||
err := underTest.InsertToken(context.Background(), expected)
|
err := underTest.InsertToken(context.Background(), expected)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -121,25 +122,25 @@ func TestToken(t *testing.T) {
|
|||||||
expected.SessionId = ""
|
expected.SessionId = ""
|
||||||
actuals, err := underTest.GetTokensByUserIdAndType(context.Background(), expected.UserId, expected.Type)
|
actuals, err := underTest.GetTokensByUserIdAndType(context.Background(), expected.UserId, expected.Type)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []*types.Token{expected}, actuals)
|
assert.Equal(t, []*auth_types.Token{expected}, actuals)
|
||||||
|
|
||||||
expected.SessionId = "sessionId"
|
expected.SessionId = "sessionId"
|
||||||
expected.UserId = uuid.Nil
|
expected.UserId = uuid.Nil
|
||||||
actuals, err = underTest.GetTokensBySessionIdAndType(context.Background(), expected.SessionId, expected.Type)
|
actuals, err = underTest.GetTokensBySessionIdAndType(context.Background(), expected.SessionId, expected.Type)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []*types.Token{expected}, actuals)
|
assert.Equal(t, []*auth_types.Token{expected}, actuals)
|
||||||
})
|
})
|
||||||
t.Run("should insert and return multiple tokens", func(t *testing.T) {
|
t.Run("should insert and return multiple tokens", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
expiresAt := createAt.Add(24 * time.Hour)
|
expiresAt := createAt.Add(24 * time.Hour)
|
||||||
userId := uuid.New()
|
userId := uuid.New()
|
||||||
expected1 := types.NewToken(userId, "sessionId", "token1", types.TokenTypeCsrf, createAt, expiresAt)
|
expected1 := auth_types.NewToken(userId, "sessionId", "token1", auth_types.TokenTypeCsrf, createAt, expiresAt)
|
||||||
expected2 := types.NewToken(userId, "sessionId", "token2", types.TokenTypeCsrf, createAt, expiresAt)
|
expected2 := auth_types.NewToken(userId, "sessionId", "token2", auth_types.TokenTypeCsrf, createAt, expiresAt)
|
||||||
|
|
||||||
err := underTest.InsertToken(context.Background(), expected1)
|
err := underTest.InsertToken(context.Background(), expected1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -150,7 +151,7 @@ func TestToken(t *testing.T) {
|
|||||||
expected2.UserId = uuid.Nil
|
expected2.UserId = uuid.Nil
|
||||||
actuals, err := underTest.GetTokensBySessionIdAndType(context.Background(), expected1.SessionId, expected1.Type)
|
actuals, err := underTest.GetTokensBySessionIdAndType(context.Background(), expected1.SessionId, expected1.Type)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
|
assert.Equal(t, []*auth_types.Token{expected1, expected2}, actuals)
|
||||||
|
|
||||||
expected1.SessionId = ""
|
expected1.SessionId = ""
|
||||||
expected2.SessionId = ""
|
expected2.SessionId = ""
|
||||||
@@ -158,49 +159,49 @@ func TestToken(t *testing.T) {
|
|||||||
expected2.UserId = userId
|
expected2.UserId = userId
|
||||||
actuals, err = underTest.GetTokensByUserIdAndType(context.Background(), userId, expected1.Type)
|
actuals, err = underTest.GetTokensByUserIdAndType(context.Background(), userId, expected1.Type)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
|
assert.Equal(t, []*auth_types.Token{expected1, expected2}, actuals)
|
||||||
})
|
})
|
||||||
t.Run("should return ErrNotFound", func(t *testing.T) {
|
t.Run("should return ErrNotFound", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
_, err := underTest.GetToken(context.Background(), "nonExistent")
|
_, err := underTest.GetToken(context.Background(), "nonExistent")
|
||||||
assert.Equal(t, db.ErrNotFound, err)
|
assert.Equal(t, core.ErrNotFound, err)
|
||||||
|
|
||||||
_, err = underTest.GetTokensByUserIdAndType(context.Background(), uuid.New(), types.TokenTypeEmailVerify)
|
_, err = underTest.GetTokensByUserIdAndType(context.Background(), uuid.New(), auth_types.TokenTypeEmailVerify)
|
||||||
assert.Equal(t, db.ErrNotFound, err)
|
assert.Equal(t, core.ErrNotFound, err)
|
||||||
|
|
||||||
_, err = underTest.GetTokensBySessionIdAndType(context.Background(), "sessionId", types.TokenTypeEmailVerify)
|
_, err = underTest.GetTokensBySessionIdAndType(context.Background(), "sessionId", auth_types.TokenTypeEmailVerify)
|
||||||
assert.Equal(t, db.ErrNotFound, err)
|
assert.Equal(t, core.ErrNotFound, err)
|
||||||
})
|
})
|
||||||
t.Run("should return ErrAlreadyExists", func(t *testing.T) {
|
t.Run("should return ErrAlreadyExists", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
user := auth_types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
||||||
|
|
||||||
err := underTest.InsertUser(context.Background(), user)
|
err := underTest.InsertUser(context.Background(), user)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = underTest.InsertUser(context.Background(), user)
|
err = underTest.InsertUser(context.Background(), user)
|
||||||
assert.Equal(t, db.ErrAlreadyExists, err)
|
assert.Equal(t, core.ErrAlreadyExists, err)
|
||||||
})
|
})
|
||||||
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
|
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := setupDb(t)
|
d := setupDb(t)
|
||||||
|
|
||||||
underTest := db.NewAuthSqlite(d)
|
underTest := authentication.NewDbSqlite(d)
|
||||||
|
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
|
user := auth_types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
|
||||||
|
|
||||||
err := underTest.InsertUser(context.Background(), user)
|
err := underTest.InsertUser(context.Background(), user)
|
||||||
assert.Equal(t, types.ErrInternal, err)
|
assert.Equal(t, core.ErrInternal, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package test_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"spend-sparrow/internal/db"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/authentication"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/core"
|
||||||
"spend-sparrow/mocks"
|
"spend-sparrow/mocks"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
settings = types.Settings{
|
settings = core.Settings{
|
||||||
Port: "",
|
Port: "",
|
||||||
BaseUrl: "",
|
BaseUrl: "",
|
||||||
Environment: "test",
|
Environment: "test",
|
||||||
@@ -30,26 +30,26 @@ func TestSignUp(t *testing.T) {
|
|||||||
t.Run("should check for correct email address", func(t *testing.T) {
|
t.Run("should check for correct email address", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
mockAuthDb := mocks.NewMockAuth(t)
|
mockAuthDb := mocks.NewMockDb(t)
|
||||||
mockRandom := mocks.NewMockRandom(t)
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(t)
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
||||||
|
|
||||||
_, err := underTest.SignUp(context.Background(), "invalid email address", "SomeStrongPassword123!")
|
_, err := underTest.SignUp(context.Background(), "invalid email address", "SomeStrongPassword123!")
|
||||||
|
|
||||||
assert.Equal(t, service.ErrInvalidEmail, err)
|
assert.Equal(t, authentication.ErrInvalidEmail, err)
|
||||||
})
|
})
|
||||||
t.Run("should check for password complexity", func(t *testing.T) {
|
t.Run("should check for password complexity", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
mockAuthDb := mocks.NewMockAuth(t)
|
mockAuthDb := mocks.NewMockDb(t)
|
||||||
mockRandom := mocks.NewMockRandom(t)
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(t)
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
||||||
|
|
||||||
weakPasswords := []string{
|
weakPasswords := []string{
|
||||||
"123!ab", // too short
|
"123!ab", // too short
|
||||||
@@ -60,13 +60,13 @@ func TestSignUp(t *testing.T) {
|
|||||||
|
|
||||||
for _, password := range weakPasswords {
|
for _, password := range weakPasswords {
|
||||||
_, err := underTest.SignUp(context.Background(), "some@valid.email", password)
|
_, err := underTest.SignUp(context.Background(), "some@valid.email", password)
|
||||||
assert.Equal(t, service.ErrInvalidPassword, err)
|
assert.Equal(t, authentication.ErrInvalidPassword, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("should signup correctly", func(t *testing.T) {
|
t.Run("should signup correctly", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
mockAuthDb := mocks.NewMockAuth(t)
|
mockAuthDb := mocks.NewMockDb(t)
|
||||||
mockRandom := mocks.NewMockRandom(t)
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(t)
|
mockMail := mocks.NewMockMail(t)
|
||||||
@@ -77,7 +77,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
salt := []byte("salt")
|
salt := []byte("salt")
|
||||||
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
expected := types.NewUser(userId, email, false, nil, false, service.GetHashPassword(password, salt), salt, createTime)
|
expected := auth_types.NewUser(userId, email, false, nil, false, authentication.GetHashPassword(password, salt), salt, createTime)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
mockClock.EXPECT().Now().Return(createTime)
|
mockClock.EXPECT().Now().Return(createTime)
|
||||||
mockAuthDb.EXPECT().InsertUser(context.Background(), expected).Return(nil)
|
mockAuthDb.EXPECT().InsertUser(context.Background(), expected).Return(nil)
|
||||||
|
|
||||||
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
||||||
actual, err := underTest.SignUp(context.Background(), email, password)
|
actual, err := underTest.SignUp(context.Background(), email, password)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -96,7 +96,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
t.Run("should return ErrAccountExists", func(t *testing.T) {
|
t.Run("should return ErrAccountExists", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
mockAuthDb := mocks.NewMockAuth(t)
|
mockAuthDb := mocks.NewMockDb(t)
|
||||||
mockRandom := mocks.NewMockRandom(t)
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(t)
|
mockMail := mocks.NewMockMail(t)
|
||||||
@@ -106,19 +106,19 @@ func TestSignUp(t *testing.T) {
|
|||||||
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
password := "SomeStrongPassword123!"
|
password := "SomeStrongPassword123!"
|
||||||
salt := []byte("salt")
|
salt := []byte("salt")
|
||||||
user := types.NewUser(userId, email, false, nil, false, service.GetHashPassword(password, salt), salt, createTime)
|
user := auth_types.NewUser(userId, email, false, nil, false, authentication.GetHashPassword(password, salt), salt, createTime)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mockRandom.EXPECT().UUID(ctx).Return(user.Id, nil)
|
mockRandom.EXPECT().UUID(ctx).Return(user.Id, nil)
|
||||||
mockRandom.EXPECT().Bytes(ctx, 16).Return(salt, nil)
|
mockRandom.EXPECT().Bytes(ctx, 16).Return(salt, nil)
|
||||||
mockClock.EXPECT().Now().Return(createTime)
|
mockClock.EXPECT().Now().Return(createTime)
|
||||||
|
|
||||||
mockAuthDb.EXPECT().InsertUser(context.Background(), user).Return(db.ErrAlreadyExists)
|
mockAuthDb.EXPECT().InsertUser(context.Background(), user).Return(core.ErrAlreadyExists)
|
||||||
|
|
||||||
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
||||||
|
|
||||||
_, err := underTest.SignUp(context.Background(), user.Email, password)
|
_, err := underTest.SignUp(context.Background(), user.Email, password)
|
||||||
assert.Equal(t, service.ErrAccountExists, err)
|
assert.Equal(t, authentication.ErrAccountExists, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,30 +127,30 @@ func TestSendVerificationMail(t *testing.T) {
|
|||||||
t.Run("should use stored token and send mail", func(t *testing.T) {
|
t.Run("should use stored token and send mail", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
token := types.NewToken(
|
token := auth_types.NewToken(
|
||||||
uuid.New(),
|
uuid.New(),
|
||||||
"sessionId",
|
"sessionId",
|
||||||
"someRandomTokenToUse",
|
"someRandomTokenToUse",
|
||||||
types.TokenTypeEmailVerify,
|
auth_types.TokenTypeEmailVerify,
|
||||||
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
|
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
|
time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
|
||||||
tokens := []*types.Token{token}
|
tokens := []*auth_types.Token{token}
|
||||||
|
|
||||||
email := "some@email.de"
|
email := "some@email.de"
|
||||||
userId := uuid.New()
|
userId := uuid.New()
|
||||||
|
|
||||||
mockAuthDb := mocks.NewMockAuth(t)
|
mockAuthDb := mocks.NewMockDb(t)
|
||||||
mockRandom := mocks.NewMockRandom(t)
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(t)
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mockAuthDb.EXPECT().GetTokensByUserIdAndType(context.Background(), userId, types.TokenTypeEmailVerify).Return(tokens, nil)
|
mockAuthDb.EXPECT().GetTokensByUserIdAndType(context.Background(), userId, auth_types.TokenTypeEmailVerify).Return(tokens, nil)
|
||||||
mockMail.EXPECT().SendMail(ctx, email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool {
|
mockMail.EXPECT().SendMail(ctx, email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool {
|
||||||
return strings.Contains(message, token.Token)
|
return strings.Contains(message, token.Token)
|
||||||
})).Return()
|
})).Return()
|
||||||
|
|
||||||
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
|
||||||
|
|
||||||
underTest.SendVerificationMail(context.Background(), userId, email)
|
underTest.SendVerificationMail(context.Background(), userId, email)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"spend-sparrow/internal"
|
"spend-sparrow/internal"
|
||||||
"spend-sparrow/internal/service"
|
"spend-sparrow/internal/auth_types"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/authentication"
|
||||||
|
"spend-sparrow/internal/core"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -117,7 +118,7 @@ func waitForReady(
|
|||||||
default:
|
default:
|
||||||
if time.Since(startTime) >= timeout {
|
if time.Since(startTime) >= timeout {
|
||||||
t.Fatal("timeout reached while waiting for endpoint")
|
t.Fatal("timeout reached while waiting for endpoint")
|
||||||
return types.ErrInternal
|
return core.ErrInternal
|
||||||
}
|
}
|
||||||
// wait a little while between checks
|
// wait a little while between checks
|
||||||
time.Sleep(250 * time.Millisecond)
|
time.Sleep(250 * time.Millisecond)
|
||||||
@@ -152,7 +153,7 @@ func getTokenAttribute(t *testing.T, data *html.Node) string {
|
|||||||
|
|
||||||
for _, attr := range data.Attr {
|
for _, attr := range data.Attr {
|
||||||
if attr.Key == "hx-headers" {
|
if attr.Key == "hx-headers" {
|
||||||
var data map[string]interface{}
|
var data map[string]any
|
||||||
err := json.Unmarshal([]byte(attr.Val), &data)
|
err := json.Unmarshal([]byte(attr.Val), &data)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
result, ok := data["Csrf-Token"].(string)
|
result, ok := data["Csrf-Token"].(string)
|
||||||
@@ -178,7 +179,7 @@ func createValidUserSession(t *testing.T, db *sqlx.DB, add string) (uuid.UUID, s
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
userId := uuid.New()
|
userId := uuid.New()
|
||||||
sessionId := "session-id" + add
|
sessionId := "session-id" + add
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
pass := authentication.GetHashPassword("password", []byte("salt"))
|
||||||
csrfToken := "my-verifying-token" + add
|
csrfToken := "my-verifying-token" + add
|
||||||
email := add + "mail@mail.de"
|
email := add + "mail@mail.de"
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ func createValidUserSession(t *testing.T, db *sqlx.DB, add string) (uuid.UUID, s
|
|||||||
|
|
||||||
_, err = db.ExecContext(context.Background(), `
|
_, err = db.ExecContext(context.Background(), `
|
||||||
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
|
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, types.TokenTypeCsrf)
|
VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, auth_types.TokenTypeCsrf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return userId, csrfToken, sessionId
|
return userId, csrfToken, sessionId
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user