Compare commits
60 Commits
restructur
...
202456cfb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 202456cfb8 | |||
| 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
|
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- run: docker build . -t spend-sparrow-test
|
||||
- run: docker rmi spend-sparrow-test
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
||||
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
|
||||
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ tmp/
|
||||
|
||||
mocks/*
|
||||
!mocks/default.go
|
||||
|
||||
arch.png
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25.5@sha256:36b4f45d2874905b9e8573b783292629bcb346d0a70d8d7150b6df545234818f AS builder_go
|
||||
FROM golang:1.25.6@sha256:06d1251c59a75761ce4ebc8b299030576233d7437c886a68b43464bad62d4bb1 AS builder_go
|
||||
WORKDIR /spend-sparrow
|
||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
@@ -13,7 +13,7 @@ RUN golangci-lint run ./...
|
||||
RUN go build -o /spend-sparrow/spend-sparrow .
|
||||
|
||||
|
||||
FROM node:24.12.0@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 AS builder_node
|
||||
FROM node:24.13.1@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 AS builder_node
|
||||
WORKDIR /spend-sparrow
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm clean-install
|
||||
@@ -21,7 +21,7 @@ COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM debian:13.2@sha256:0d01188e8dd0ac63bf155900fad49279131a876a1ea7fac917c62e87ccb2732d
|
||||
FROM debian:13.3@sha256:2c91e484d93f0830a7e05a2b9d92a7b102be7cab562198b984a84fdbc7806d91
|
||||
WORKDIR /spend-sparrow
|
||||
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
||||
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
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
go install github.com/vektra/mockery/v2@latest
|
||||
|
||||
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." &
|
||||
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
|
||||
|
||||
read -n1 -s
|
||||
kill $(jobs -p)
|
||||
|
||||
read -n1 -s -r
|
||||
kill "$(jobs -p)"
|
||||
|
||||
14
go.mod
14
go.mod
@@ -2,15 +2,15 @@ module spend-sparrow
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.25.5
|
||||
toolchain go1.25.6
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.960
|
||||
github.com/a-h/templ v0.3.977
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
|
||||
@@ -25,8 +25,8 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/net v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,8 +43,8 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
|
||||
24
go.sum
24
go.sum
@@ -1,7 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
|
||||
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -38,8 +38,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -86,14 +86,14 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"github.com/a-h/templ"
|
||||
"net/http"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/utils"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -32,7 +31,7 @@ func (h Handler) handleAccountPage() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ func (h Handler) handleAccountItemComp() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +85,7 @@ func (h Handler) handleUpdateAccount() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,7 +120,7 @@ func (h Handler) handleDeleteAccount() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/service"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -46,7 +44,7 @@ func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, name string
|
||||
return nil, core.ErrInternal
|
||||
}
|
||||
|
||||
err = service.ValidateString(name, "name")
|
||||
err = core.ValidateString(name, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +68,7 @@ func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, name string
|
||||
r, err := s.db.NamedExecContext(ctx, `
|
||||
INSERT INTO account (id, user_id, name, current_balance, oink_balance, created_at, created_by)
|
||||
VALUES (:id, :user_id, :name, :current_balance, :oink_balance, :created_at, :created_by)`, account)
|
||||
err = db.TransformAndLogDbError(ctx, "account Insert", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "account Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -82,7 +80,7 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
err := service.ValidateString(name, "name")
|
||||
err := core.ValidateString(name, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +91,7 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,7 +101,7 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
|
||||
|
||||
var account Account
|
||||
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 errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("account %v not found: %w", id, core.ErrBadRequest)
|
||||
@@ -124,13 +122,13 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,7 +149,7 @@ func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string)
|
||||
var account Account
|
||||
err = s.db.GetContext(ctx, &account, `
|
||||
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 {
|
||||
slog.ErrorContext(ctx, "account get", "err", err)
|
||||
return nil, err
|
||||
@@ -168,7 +166,7 @@ func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*Acco
|
||||
accounts := make([]*Account, 0)
|
||||
err := s.db.SelectContext(ctx, &accounts, `
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -187,7 +185,7 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,7 +195,7 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
|
||||
|
||||
transactionsCount := 0
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -206,13 +204,13 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "account Delete", res, err)
|
||||
err = core.TransformAndLogDbError(ctx, "account Delete", res, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package account
|
||||
|
||||
import "spend-sparrow/internal/template/svg"
|
||||
import "spend-sparrow/internal/types"
|
||||
import (
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/template/svg"
|
||||
)
|
||||
|
||||
templ template(accounts []*Account) {
|
||||
<div class="max-w-6xl mt-10 mx-auto">
|
||||
@@ -11,7 +13,9 @@ templ template(accounts []*Account) {
|
||||
hx-swap="afterbegin"
|
||||
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>
|
||||
</button>
|
||||
<div id="account-items" class="my-6 flex flex-col items-center">
|
||||
@@ -82,9 +86,9 @@ templ accountItem(account *Account) {
|
||||
<div class="text-xl flex justify-end gap-4">
|
||||
<p class="mr-auto">{ account.Name }</p>
|
||||
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 {
|
||||
<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
|
||||
href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/authentication/template"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -62,9 +61,9 @@ func (handler HandlerImpl) handleSignInPage() http.HandlerFunc {
|
||||
user := core.GetUser(r)
|
||||
if user != nil {
|
||||
if !user.EmailVerified {
|
||||
utils.DoRedirect(w, r, "/auth/verify")
|
||||
core.DoRedirect(w, r, "/auth/verify")
|
||||
} else {
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -79,7 +78,7 @@ func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) {
|
||||
user, err := core.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) {
|
||||
session := core.GetSession(r)
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
@@ -97,17 +96,17 @@ func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
|
||||
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
} else {
|
||||
utils.DoRedirect(w, r, "/auth/verify")
|
||||
core.DoRedirect(w, r, "/auth/verify")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,9 +119,9 @@ func (handler HandlerImpl) handleSignUpPage() http.HandlerFunc {
|
||||
|
||||
if user != nil {
|
||||
if !user.EmailVerified {
|
||||
utils.DoRedirect(w, r, "/auth/verify")
|
||||
core.DoRedirect(w, r, "/auth/verify")
|
||||
} else {
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -138,12 +137,12 @@ func (handler HandlerImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,7 +157,7 @@ func (handler HandlerImpl) handleVerifyResendComp() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +199,7 @@ func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
|
||||
var email = r.FormValue("email")
|
||||
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)
|
||||
user, err := handler.service.SignUp(r.Context(), email, password)
|
||||
if err != nil {
|
||||
@@ -215,19 +214,19 @@ func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
|
||||
if err != nil {
|
||||
switch {
|
||||
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
|
||||
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
|
||||
case errors.Is(err, ErrInvalidPassword):
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", ErrInvalidPassword.Error(), http.StatusBadRequest)
|
||||
core.TriggerToastWithStatus(r.Context(), w, r, "error", ErrInvalidPassword.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// If err is "service.ErrAccountExists", then just continue
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
||||
core.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +255,7 @@ func (handler HandlerImpl) handleSignOut() http.HandlerFunc {
|
||||
}
|
||||
|
||||
http.SetCookie(w, &c)
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +265,7 @@ func (handler HandlerImpl) handleDeleteAccountPage() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,7 +280,7 @@ func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,14 +289,14 @@ func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||
err := handler.service.DeleteAccount(r.Context(), user, password)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +309,7 @@ func (handler HandlerImpl) handleChangePasswordPage() http.HandlerFunc {
|
||||
user := core.GetUser(r)
|
||||
|
||||
if user == nil && !isPasswordReset {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -326,7 +325,7 @@ func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
|
||||
session := core.GetSession(r)
|
||||
user := core.GetUser(r)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -335,11 +334,11 @@ func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
|
||||
|
||||
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
|
||||
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
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||
core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +348,7 @@ func (handler HandlerImpl) handleForgotPasswordPage() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user != nil {
|
||||
utils.DoRedirect(w, r, "/")
|
||||
core.DoRedirect(w, r, "/")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -364,19 +363,19 @@ func (handler HandlerImpl) handleForgotPasswordComp() http.HandlerFunc {
|
||||
|
||||
email := r.FormValue("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
|
||||
}
|
||||
|
||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||
_, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||
err := handler.service.SendForgotPasswordMail(r.Context(), email)
|
||||
return nil, err
|
||||
})
|
||||
|
||||
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 {
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
||||
core.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,7 +387,7 @@ func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
||||
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -397,9 +396,9 @@ func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
||||
|
||||
err = handler.service.ForgotPassword(r.Context(), token, newPass)
|
||||
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 {
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||
core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
mailTemplate "spend-sparrow/internal/template/mail"
|
||||
"spend-sparrow/internal/types"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -54,10 +53,10 @@ type ServiceImpl struct {
|
||||
random core.Random
|
||||
clock core.Clock
|
||||
mail core.Mail
|
||||
serverSettings *types.Settings
|
||||
serverSettings *core.Settings
|
||||
}
|
||||
|
||||
func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *types.Settings) *ServiceImpl {
|
||||
func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *core.Settings) *ServiceImpl {
|
||||
return &ServiceImpl{
|
||||
db: db,
|
||||
random: random,
|
||||
|
||||
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"`
|
||||
}
|
||||
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,44 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"spend-sparrow/internal/utils"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
|
||||
return
|
||||
case errors.Is(err, ErrBadRequest):
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
|
||||
return
|
||||
case errors.Is(err, ErrNotFound):
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func extractErrorMessage(err error) string {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.SplitN(errMsg, ":", 2)[0]
|
||||
}
|
||||
|
||||
func UpdateSpan(r *http.Request) {
|
||||
currentSpan := trace.SpanFromContext(r.Context())
|
||||
if currentSpan != nil {
|
||||
currentSpan.SetAttributes(attribute.String("http.pattern", r.Pattern))
|
||||
currentSpan.SetAttributes(attribute.String("http.pattern.id", r.PathValue("id")))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package core
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("the value does not exist")
|
||||
@@ -11,3 +18,53 @@ var (
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package utils
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,6 +1,9 @@
|
||||
package core
|
||||
|
||||
import "spend-sparrow/internal/template/svg"
|
||||
import (
|
||||
"spend-sparrow/internal/template/svg"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -87,9 +90,17 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
|
||||
|
||||
templ navigation(path string) {
|
||||
<nav class="w-64 text-nowrap flex gap-2 flex-col text-lg mt-5 px-5 pt-2">
|
||||
<a class={ layoutLinkClass(path == "/dashboard") } href="/dashboard">Dashboard</a>
|
||||
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
|
||||
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
|
||||
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
|
||||
<a class={ layoutLinkClass(strings.HasPrefix(path, "/dashboard")) } href="/dashboard">Dashboard</a>
|
||||
<a class={ layoutLinkClass(strings.HasPrefix(path, "/transaction")) } href="/transaction">Transaction</a>
|
||||
<a class={ layoutLinkClass(strings.HasPrefix(path, "/treasurechest")) } href="/treasurechest">Treasure Chest</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>
|
||||
}
|
||||
|
||||
templ ErrorComp(err error) {
|
||||
<div>
|
||||
The following error occured: { err.Error() }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package log
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/smtp"
|
||||
"spend-sparrow/internal/types"
|
||||
)
|
||||
|
||||
type Mail interface {
|
||||
@@ -14,10 +13,10 @@ type Mail interface {
|
||||
}
|
||||
|
||||
type MailImpl struct {
|
||||
server *types.Settings
|
||||
server *Settings
|
||||
}
|
||||
|
||||
func NewMail(server *types.Settings) MailImpl {
|
||||
func NewMail(server *Settings) MailImpl {
|
||||
return MailImpl{server: server}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package db
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/core"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"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{})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Could not create Migration instance", "err", err)
|
||||
return core.ErrInternal
|
||||
return ErrInternal
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
@@ -34,14 +33,14 @@ func RunMigrations(ctx context.Context, db *sqlx.DB, pathPrefix string) error {
|
||||
driver)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Could not create migrations instance", "err", err)
|
||||
return core.ErrInternal
|
||||
return ErrInternal
|
||||
}
|
||||
|
||||
m.Log = migrationLogger{}
|
||||
|
||||
if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
slog.ErrorContext(ctx, "Could not run migrations", "err", err)
|
||||
return core.ErrInternal
|
||||
return ErrInternal
|
||||
}
|
||||
|
||||
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,4 +1,4 @@
|
||||
package types
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,25 +1,20 @@
|
||||
package service
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"spend-sparrow/internal/core"
|
||||
)
|
||||
|
||||
const (
|
||||
DECIMALS_MULTIPLIER = 100
|
||||
)
|
||||
|
||||
var (
|
||||
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`)
|
||||
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?\(\)]+$`)
|
||||
)
|
||||
|
||||
func ValidateString(value string, fieldName string) error {
|
||||
switch {
|
||||
case value == "":
|
||||
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, core.ErrBadRequest)
|
||||
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)
|
||||
case !safeInputRegex.MatchString(value):
|
||||
return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, core.ErrBadRequest)
|
||||
return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, ErrBadRequest)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -1,51 +1,49 @@
|
||||
package handler
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/service"
|
||||
"spend-sparrow/internal/template/dashboard"
|
||||
"spend-sparrow/internal/utils"
|
||||
"spend-sparrow/internal/treasure_chest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Dashboard interface {
|
||||
type Handler interface {
|
||||
Handle(router *http.ServeMux)
|
||||
}
|
||||
|
||||
type DashboardImpl struct {
|
||||
type HandlerImpl struct {
|
||||
r *core.Render
|
||||
d *service.Dashboard
|
||||
treasureChest service.TreasureChest
|
||||
s *Service
|
||||
treasureChest treasure_chest.Service
|
||||
}
|
||||
|
||||
func NewDashboard(r *core.Render, d *service.Dashboard, treasureChest service.TreasureChest) Dashboard {
|
||||
return DashboardImpl{
|
||||
func NewHandler(r *core.Render, s *Service, treasureChest treasure_chest.Service) Handler {
|
||||
return HandlerImpl{
|
||||
r: r,
|
||||
d: d,
|
||||
s: s,
|
||||
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/main-chart", handler.handleDashboardMainChart())
|
||||
router.Handle("GET /dashboard/treasure-chests", handler.handleDashboardTreasureChests())
|
||||
router.Handle("GET /dashboard/treasure-chest", handler.handleDashboardTreasureChest())
|
||||
router.Handle("GET /dashboard/main-chart", handler.handleMainChart())
|
||||
router.Handle("GET /dashboard/treasure-chests", handler.handleTreasureChests())
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,18 +53,18 @@ func (handler DashboardImpl) handleDashboard() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
comp := dashboard.Dashboard(treasureChests)
|
||||
comp := DashboardComp(treasureChests)
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
|
||||
series, err := handler.d.MainChart(r.Context(), user)
|
||||
series, err := handler.s.MainChart(r.Context(), user)
|
||||
if err != nil {
|
||||
core.HandleError(w, r, err)
|
||||
return
|
||||
@@ -126,13 +124,13 @@ func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
|
||||
func (handler HandlerImpl) handleTreasureChests() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
|
||||
treeList, err := handler.d.TreasureChests(r.Context(), user)
|
||||
treeList, err := handler.s.TreasureChests(r.Context(), user)
|
||||
if err != nil {
|
||||
core.HandleError(w, r, err)
|
||||
return
|
||||
@@ -181,7 +179,7 @@ func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
|
||||
func (handler HandlerImpl) handleTreasureChest() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
@@ -200,7 +198,7 @@ func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
|
||||
treasureChestId = &id
|
||||
}
|
||||
|
||||
series, err := handler.d.TreasureChest(r.Context(), user, treasureChestId)
|
||||
series, err := handler.s.TreasureChest(r.Context(), user, treasureChestId)
|
||||
if err != nil {
|
||||
core.HandleError(w, r, err)
|
||||
return
|
||||
@@ -1,49 +1,50 @@
|
||||
package service
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/transaction"
|
||||
"spend-sparrow/internal/treasure_chest"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type Dashboard struct {
|
||||
type Service struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewDashboard(db *sqlx.DB) *Dashboard {
|
||||
return &Dashboard{
|
||||
func NewService(db *sqlx.DB) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Dashboard) MainChart(
|
||||
func (s Service) MainChart(
|
||||
ctx context.Context,
|
||||
user *auth_types.User,
|
||||
) ([]types.DashboardMainChartEntry, error) {
|
||||
) ([]DashboardMainChartEntry, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
|
||||
transactions := make([]types.Transaction, 0)
|
||||
transactions := make([]transaction.Transaction, 0)
|
||||
err := s.db.SelectContext(ctx, &transactions, `
|
||||
SELECT *
|
||||
FROM "transaction"
|
||||
WHERE user_id = ?
|
||||
ORDER BY timestamp`, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeEntries := make([]types.DashboardMainChartEntry, 0)
|
||||
timeEntries := make([]DashboardMainChartEntry, 0)
|
||||
|
||||
var lastEntry *types.DashboardMainChartEntry
|
||||
var lastEntry *DashboardMainChartEntry
|
||||
|
||||
for _, t := range transactions {
|
||||
if t.Error != nil {
|
||||
@@ -52,14 +53,14 @@ func (s Dashboard) MainChart(
|
||||
|
||||
newDay := t.Timestamp.Truncate(24 * time.Hour)
|
||||
if lastEntry == nil {
|
||||
lastEntry = &types.DashboardMainChartEntry{
|
||||
lastEntry = &DashboardMainChartEntry{
|
||||
Day: newDay,
|
||||
Value: 0,
|
||||
Savings: 0,
|
||||
}
|
||||
} else if lastEntry.Day != newDay {
|
||||
timeEntries = append(timeEntries, *lastEntry)
|
||||
lastEntry = &types.DashboardMainChartEntry{
|
||||
lastEntry = &DashboardMainChartEntry{
|
||||
Day: newDay,
|
||||
Value: lastEntry.Value,
|
||||
Savings: lastEntry.Savings,
|
||||
@@ -82,37 +83,37 @@ func (s Dashboard) MainChart(
|
||||
return timeEntries, nil
|
||||
}
|
||||
|
||||
func (s Dashboard) TreasureChests(
|
||||
func (s Service) TreasureChests(
|
||||
ctx context.Context,
|
||||
user *auth_types.User,
|
||||
) ([]*types.DashboardTreasureChest, error) {
|
||||
) ([]*DashboardTreasureChest, error) {
|
||||
if user == nil {
|
||||
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 = db.TransformAndLogDbError(ctx, "dashboard TreasureChests", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "dashboard TreasureChests", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
treasureChests = sortTreasureChests(treasureChests)
|
||||
treasureChests = treasure_chest.SortTreasureChests(treasureChests)
|
||||
|
||||
result := make([]*types.DashboardTreasureChest, 0)
|
||||
result := make([]*DashboardTreasureChest, 0)
|
||||
|
||||
for _, t := range treasureChests {
|
||||
if t.ParentId == nil {
|
||||
result = append(result, &types.DashboardTreasureChest{
|
||||
result = append(result, &DashboardTreasureChest{
|
||||
Name: t.Name,
|
||||
Value: t.CurrentBalance,
|
||||
Children: make([]types.DashboardTreasureChest, 0),
|
||||
Children: make([]DashboardTreasureChest, 0),
|
||||
})
|
||||
} 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,
|
||||
Value: t.CurrentBalance,
|
||||
Children: make([]types.DashboardTreasureChest, 0),
|
||||
Children: make([]DashboardTreasureChest, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,30 +121,30 @@ func (s Dashboard) TreasureChests(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s Dashboard) TreasureChest(
|
||||
func (s Service) TreasureChest(
|
||||
ctx context.Context,
|
||||
user *auth_types.User,
|
||||
treausureChestId *uuid.UUID,
|
||||
) ([]types.DashboardMainChartEntry, error) {
|
||||
) ([]DashboardMainChartEntry, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
|
||||
transactions := make([]types.Transaction, 0)
|
||||
transactions := make([]transaction.Transaction, 0)
|
||||
err := s.db.SelectContext(ctx, &transactions, `
|
||||
SELECT *
|
||||
FROM "transaction"
|
||||
WHERE user_id = ?
|
||||
AND treasure_chest_id = ?
|
||||
ORDER BY timestamp`, user.Id, treausureChestId)
|
||||
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeEntries := make([]types.DashboardMainChartEntry, 0)
|
||||
timeEntries := make([]DashboardMainChartEntry, 0)
|
||||
|
||||
var lastEntry *types.DashboardMainChartEntry
|
||||
var lastEntry *DashboardMainChartEntry
|
||||
|
||||
for _, t := range transactions {
|
||||
if t.Error != nil {
|
||||
@@ -152,13 +153,13 @@ func (s Dashboard) TreasureChest(
|
||||
|
||||
newDay := t.Timestamp.Truncate(24 * time.Hour)
|
||||
if lastEntry == nil {
|
||||
lastEntry = &types.DashboardMainChartEntry{
|
||||
lastEntry = &DashboardMainChartEntry{
|
||||
Day: newDay,
|
||||
Value: 0,
|
||||
}
|
||||
} else if lastEntry.Day != newDay {
|
||||
timeEntries = append(timeEntries, *lastEntry)
|
||||
lastEntry = &types.DashboardMainChartEntry{
|
||||
lastEntry = &DashboardMainChartEntry{
|
||||
Day: newDay,
|
||||
Value: lastEntry.Value,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
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 id="main-chart" 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"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/core"
|
||||
)
|
||||
|
||||
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.ErrNotFound
|
||||
}
|
||||
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
|
||||
return core.ErrInternal
|
||||
}
|
||||
|
||||
if r != nil {
|
||||
rows, err := r.RowsAffected()
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
|
||||
return core.ErrInternal
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
slog.InfoContext(ctx, "row not found", "module", module)
|
||||
return core.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import (
|
||||
"os/signal"
|
||||
"spend-sparrow/internal/account"
|
||||
"spend-sparrow/internal/authentication"
|
||||
"spend-sparrow/internal/budget"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/dashboard"
|
||||
"spend-sparrow/internal/handler"
|
||||
"spend-sparrow/internal/handler/middleware"
|
||||
"spend-sparrow/internal/log"
|
||||
"spend-sparrow/internal/service"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/tag"
|
||||
"spend-sparrow/internal/transaction"
|
||||
"spend-sparrow/internal/transaction_recurring"
|
||||
"spend-sparrow/internal/treasure_chest"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -29,8 +31,8 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
|
||||
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
otelEnabled := types.IsOtelEnabled(env)
|
||||
if otelEnabled {
|
||||
isOtelEnabled := core.IsOtelEnabled(env)
|
||||
if isOtelEnabled {
|
||||
// use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled
|
||||
otelShutdown, err := setupOTelSDK(context.Background())
|
||||
if err != nil {
|
||||
@@ -46,19 +48,19 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
|
||||
cancel()
|
||||
}()
|
||||
|
||||
slog.SetDefault(log.NewLogPropagator())
|
||||
slog.SetDefault(core.NewLogPropagator())
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Starting server...")
|
||||
|
||||
// init server settings
|
||||
serverSettings, err := types.NewSettingsFromEnv(ctx, env)
|
||||
serverSettings, err := core.NewSettingsFromEnv(ctx, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// init db
|
||||
err = db.RunMigrations(ctx, database, migrationsPrefix)
|
||||
err = core.RunMigrations(ctx, database, migrationsPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not run migrations: %w", err)
|
||||
}
|
||||
@@ -66,7 +68,7 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
|
||||
// init server
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + serverSettings.Port,
|
||||
Handler: createHandlerWithServices(ctx, database, serverSettings),
|
||||
Handler: createHandlerWithServices(ctx, database, serverSettings, isOtelEnabled),
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
}
|
||||
go startServer(ctx, httpServer)
|
||||
@@ -105,10 +107,12 @@ func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler {
|
||||
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *core.Settings, isOtelEnabled bool) http.Handler {
|
||||
var router = http.NewServeMux()
|
||||
|
||||
authDb := authentication.NewDbSqlite(d)
|
||||
budgetDb := budget.NewDbSqlite(d)
|
||||
tagDb := tag.NewDbSqlite(d)
|
||||
|
||||
randomService := core.NewRandom()
|
||||
clockService := core.NewClock()
|
||||
@@ -116,21 +120,25 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
||||
|
||||
authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings)
|
||||
accountService := account.NewServiceImpl(d, randomService, clockService)
|
||||
treasureChestService := service.NewTreasureChest(d, randomService, clockService)
|
||||
transactionService := service.NewTransaction(d, randomService, clockService)
|
||||
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
|
||||
dashboardService := service.NewDashboard(d)
|
||||
treasureChestService := treasure_chest.NewService(d, randomService, clockService)
|
||||
transactionService := transaction.NewService(d, randomService, clockService)
|
||||
transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService)
|
||||
dashboardService := dashboard.NewService(d)
|
||||
budgetService := budget.NewService(budgetDb, randomService, clockService)
|
||||
tagService := tag.NewService(tagDb, randomService, clockService)
|
||||
|
||||
render := core.NewRender()
|
||||
indexHandler := handler.NewIndex(render, clockService)
|
||||
dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService)
|
||||
dashboardHandler := dashboard.NewHandler(render, dashboardService, treasureChestService)
|
||||
authHandler := authentication.NewHandler(authService, render)
|
||||
accountHandler := account.NewHandler(accountService, render)
|
||||
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
|
||||
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render)
|
||||
transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render)
|
||||
treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render)
|
||||
transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, 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)
|
||||
dashboardHandler.Handle(router)
|
||||
@@ -139,6 +147,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
||||
authHandler.Handle(router)
|
||||
transactionHandler.Handle(router)
|
||||
transactionRecurringHandler.Handle(router)
|
||||
budgetHandler.Handle(router)
|
||||
tagHandler.Handle(router)
|
||||
|
||||
// Serve static files (CSS, JS and images)
|
||||
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
|
||||
@@ -150,7 +160,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
||||
middleware.CrossSiteRequestForgery(authService),
|
||||
middleware.Authenticate(authService),
|
||||
middleware.Gzip,
|
||||
middleware.Log,
|
||||
middleware.Log(isOtelEnabled),
|
||||
)
|
||||
|
||||
wrapper = otelhttp.NewHandler(wrapper, "http.request")
|
||||
@@ -158,8 +168,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
|
||||
return wrapper
|
||||
}
|
||||
|
||||
func dailyTaskTimer(ctx context.Context, transactionRecurring service.TransactionRecurring, auth authentication.Service) {
|
||||
runDailyTasks(ctx, transactionRecurring, auth)
|
||||
func dailyTaskTimer(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
|
||||
runDailyTasks(ctx, transaction, auth)
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -168,13 +178,13 @@ func dailyTaskTimer(ctx context.Context, transactionRecurring service.Transactio
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runDailyTasks(ctx, transactionRecurring, auth)
|
||||
runDailyTasks(ctx, transaction, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runDailyTasks(ctx context.Context, transactionRecurring service.TransactionRecurring, auth authentication.Service) {
|
||||
func runDailyTasks(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
|
||||
slog.InfoContext(ctx, "Running daily tasks")
|
||||
_ = transactionRecurring.GenerateTransactions(ctx)
|
||||
_ = transaction.GenerateRecurringTransactions(ctx)
|
||||
_ = auth.CleanupSessionsAndTokens(ctx)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"spend-sparrow/internal/authentication"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -52,7 +51,7 @@ func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) htt
|
||||
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) {
|
||||
slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken)
|
||||
if r.Header.Get("Hx-Request") == "true" {
|
||||
utils.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
|
||||
core.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
|
||||
}
|
||||
@@ -63,7 +62,7 @@ func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) htt
|
||||
token, err := auth.GetCsrfToken(ctx, session)
|
||||
if err != nil {
|
||||
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 {
|
||||
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -17,21 +17,28 @@ func (w *WrappedWriter) WriteHeader(code int) {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func Log(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
func Log(enabled bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !enabled {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
wrapped := &WrappedWriter{
|
||||
ResponseWriter: w,
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
start := time.Now()
|
||||
|
||||
slog.InfoContext(r.Context(), "request",
|
||||
"remoteAddr", r.RemoteAddr,
|
||||
"status", wrapped.StatusCode,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"duration", time.Since(start).String())
|
||||
})
|
||||
wrapped := &WrappedWriter{
|
||||
ResponseWriter: w,
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
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 (
|
||||
"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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/template"
|
||||
"spend-sparrow/internal/utils"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
@@ -36,7 +35,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
||||
|
||||
user := core.GetUser(r)
|
||||
|
||||
htmx := utils.IsHtmx(r)
|
||||
htmx := core.IsHtmx(r)
|
||||
|
||||
var comp templ.Component
|
||||
|
||||
@@ -46,7 +45,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
||||
status = http.StatusNotFound
|
||||
} else {
|
||||
if user != nil {
|
||||
utils.DoRedirect(w, r, "/dashboard")
|
||||
core.DoRedirect(w, r, "/dashboard")
|
||||
return
|
||||
} else {
|
||||
comp = template.Index()
|
||||
|
||||
@@ -16,11 +16,6 @@ import (
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
var (
|
||||
otelEndpoint = "otel-collector:4317"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
@@ -50,10 +45,7 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
resources, err := resource.New(
|
||||
ctx,
|
||||
resource.WithAttributes(semconv.ServiceName("spend-sparrow")),
|
||||
)
|
||||
resources, err := resource.New(ctx)
|
||||
if err != nil {
|
||||
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) {
|
||||
exp, err := otlptracegrpc.New(
|
||||
ctx,
|
||||
otlptracegrpc.WithEndpoint(otelEndpoint),
|
||||
otlptracegrpc.WithInsecure(),
|
||||
)
|
||||
exp, err := otlptracegrpc.New(ctx)
|
||||
if err != nil {
|
||||
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) {
|
||||
exp, err := otlpmetricgrpc.New(
|
||||
ctx,
|
||||
otlpmetricgrpc.WithInsecure(),
|
||||
otlpmetricgrpc.WithEndpoint(otelEndpoint))
|
||||
exp, err := otlpmetricgrpc.New(ctx)
|
||||
if err != nil {
|
||||
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) {
|
||||
logExporter, err := otlploggrpc.New(
|
||||
ctx,
|
||||
otlploggrpc.WithInsecure(),
|
||||
otlploggrpc.WithEndpoint(otelEndpoint))
|
||||
logExporter, err := otlploggrpc.New(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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,2 +0,0 @@
|
||||
package dashboard
|
||||
|
||||
@@ -19,7 +19,7 @@ templ Eye() {
|
||||
}
|
||||
|
||||
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>
|
||||
</svg>
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package transaction
|
||||
@@ -1 +0,0 @@
|
||||
package transaction_recurring
|
||||
@@ -1 +0,0 @@
|
||||
package treasurechest
|
||||
@@ -1,4 +1,4 @@
|
||||
package handler
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"net/http"
|
||||
"spend-sparrow/internal/account"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/service"
|
||||
t "spend-sparrow/internal/template/transaction"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/utils"
|
||||
"spend-sparrow/internal/treasure_chest"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -17,19 +15,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Transaction interface {
|
||||
const (
|
||||
DECIMALS_MULTIPLIER = 100
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
Handle(router *http.ServeMux)
|
||||
}
|
||||
|
||||
type TransactionImpl struct {
|
||||
s service.Transaction
|
||||
type HandlerImpl struct {
|
||||
s Service
|
||||
account account.Service
|
||||
treasureChest service.TreasureChest
|
||||
treasureChest treasure_chest.Service
|
||||
r *core.Render
|
||||
}
|
||||
|
||||
func NewTransaction(s service.Transaction, account account.Service, treasureChest service.TreasureChest, r *core.Render) Transaction {
|
||||
return TransactionImpl{
|
||||
func NewHandler(s Service, account account.Service, treasureChest treasure_chest.Service, r *core.Render) Handler {
|
||||
return HandlerImpl{
|
||||
s: s,
|
||||
account: account,
|
||||
treasureChest: treasureChest,
|
||||
@@ -37,7 +39,7 @@ func NewTransaction(s service.Transaction, account account.Service, treasureChes
|
||||
}
|
||||
}
|
||||
|
||||
func (h TransactionImpl) Handle(r *http.ServeMux) {
|
||||
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||
r.Handle("GET /transaction", h.handleTransactionPage())
|
||||
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
|
||||
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
|
||||
@@ -45,17 +47,17 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
|
||||
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
|
||||
}
|
||||
|
||||
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
||||
func (h HandlerImpl) handleTransactionPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
filter := types.TransactionItemsFilter{
|
||||
filter := TransactionItemsFilter{
|
||||
AccountId: r.URL.Query().Get("account-id"),
|
||||
TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
@@ -82,23 +84,23 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
||||
|
||||
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||
|
||||
items := t.TransactionItems(transactions, accountMap, treasureChestMap)
|
||||
if utils.IsHtmx(r) {
|
||||
items := TransactionItems(transactions, accountMap, treasureChestMap)
|
||||
if core.IsHtmx(r) {
|
||||
h.r.Render(r, w, items)
|
||||
} else {
|
||||
comp := t.Transaction(items, filter, accounts, treasureChests)
|
||||
comp := TransactionComp(items, filter, accounts, treasureChests)
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,7 +118,7 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "new" {
|
||||
comp := t.EditTransaction(nil, accounts, treasureChests)
|
||||
comp := EditTransaction(nil, accounts, treasureChests)
|
||||
h.r.Render(r, w, comp)
|
||||
return
|
||||
}
|
||||
@@ -129,22 +131,22 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
|
||||
|
||||
var comp templ.Component
|
||||
if r.URL.Query().Get("edit") == "true" {
|
||||
comp = t.EditTransaction(transaction, accounts, treasureChests)
|
||||
comp = EditTransaction(transaction, accounts, treasureChests)
|
||||
} else {
|
||||
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||
comp = t.TransactionItem(transaction, accountMap, treasureChestMap)
|
||||
comp = TransactionItem(transaction, accountMap, treasureChestMap)
|
||||
}
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,7 +191,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
|
||||
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"))
|
||||
if err != nil {
|
||||
@@ -197,7 +199,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
input := types.Transaction{
|
||||
input := Transaction{
|
||||
Id: id,
|
||||
AccountId: accountId,
|
||||
TreasureChestId: treasureChestId,
|
||||
@@ -207,7 +209,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
var transaction *types.Transaction
|
||||
var transaction *Transaction
|
||||
if idStr == "new" {
|
||||
transaction, err = h.s.Add(r.Context(), nil, user, input)
|
||||
if err != nil {
|
||||
@@ -235,18 +237,18 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
}
|
||||
|
||||
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||
comp := t.TransactionItem(transaction, accountMap, treasureChestMap)
|
||||
comp := TransactionItem(transaction, accountMap, treasureChestMap)
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -256,17 +258,17 @@ func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +282,7 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (h TransactionImpl) getTransactionData(accounts []*account.Account, treasureChests []*types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
|
||||
func (h HandlerImpl) getTransactionData(accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
|
||||
accountMap := make(map[uuid.UUID]string, 0)
|
||||
for _, account := range accounts {
|
||||
accountMap[account.Id] = account.Name
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/transaction_recurring"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -18,31 +18,32 @@ import (
|
||||
|
||||
const page_size = 25
|
||||
|
||||
type Transaction interface {
|
||||
Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transaction types.Transaction) (*types.Transaction, error)
|
||||
Update(ctx context.Context, user *auth_types.User, transaction types.Transaction) (*types.Transaction, error)
|
||||
Get(ctx context.Context, user *auth_types.User, id string) (*types.Transaction, error)
|
||||
GetAll(ctx context.Context, user *auth_types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
|
||||
type Service interface {
|
||||
Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transaction Transaction) (*Transaction, error)
|
||||
Update(ctx context.Context, user *auth_types.User, transaction Transaction) (*Transaction, error)
|
||||
Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error)
|
||||
GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error)
|
||||
Delete(ctx context.Context, user *auth_types.User, id string) error
|
||||
|
||||
RecalculateBalances(ctx context.Context, user *auth_types.User) error
|
||||
GenerateRecurringTransactions(ctx context.Context) error
|
||||
}
|
||||
|
||||
type TransactionImpl struct {
|
||||
type ServiceImpl struct {
|
||||
db *sqlx.DB
|
||||
clock core.Clock
|
||||
random core.Random
|
||||
}
|
||||
|
||||
func NewTransaction(db *sqlx.DB, random core.Random, clock core.Clock) Transaction {
|
||||
return TransactionImpl{
|
||||
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||
return ServiceImpl{
|
||||
db: db,
|
||||
clock: clock,
|
||||
random: random,
|
||||
}
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transactionInput types.Transaction) (*types.Transaction, error) {
|
||||
func (s ServiceImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transactionInput Transaction) (*Transaction, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -52,7 +53,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
|
||||
if tx == nil {
|
||||
ownsTransaction = true
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,7 +72,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
|
||||
party, description, error, created_at, created_by)
|
||||
VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp,
|
||||
: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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,7 +82,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
|
||||
UPDATE account
|
||||
SET current_balance = current_balance + ?
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,7 +93,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
|
||||
UPDATE treasure_chest
|
||||
SET current_balance = current_balance + ?
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,7 +101,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
|
||||
|
||||
if ownsTransaction {
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Add", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,13 +110,13 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, input types.Transaction) (*types.Transaction, error) {
|
||||
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Transaction) (*Transaction, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,9 +124,9 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
_ = 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 = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, core.ErrBadRequest)
|
||||
@@ -138,7 +139,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
UPDATE account
|
||||
SET current_balance = current_balance - ?
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
UPDATE treasure_chest
|
||||
SET current_balance = current_balance - ?
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -164,7 +165,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
UPDATE account
|
||||
SET current_balance = current_balance + ?
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,7 +175,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
UPDATE treasure_chest
|
||||
SET current_balance = current_balance + ?
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -194,13 +195,13 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,7 +209,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id string) (*types.Transaction, error) {
|
||||
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -218,9 +219,9 @@ func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id stri
|
||||
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||
}
|
||||
|
||||
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 = db.TransformAndLogDbError(ctx, "transaction Get", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Get", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("transaction %v not found: %w", id, core.ErrBadRequest)
|
||||
@@ -231,7 +232,7 @@ func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id stri
|
||||
return &transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) {
|
||||
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -251,7 +252,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
|
||||
}
|
||||
}
|
||||
|
||||
transactions := make([]*types.Transaction, 0)
|
||||
transactions := make([]*Transaction, 0)
|
||||
err = s.db.SelectContext(ctx, &transactions, `
|
||||
SELECT *
|
||||
FROM "transaction"
|
||||
@@ -271,7 +272,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
|
||||
filter.Error,
|
||||
page_size,
|
||||
offset)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -279,7 +280,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
|
||||
return transactions, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||
if user == nil {
|
||||
return core.ErrUnauthorized
|
||||
}
|
||||
@@ -290,7 +291,7 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -298,9 +299,9 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
|
||||
_ = 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 = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -311,7 +312,7 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
|
||||
SET current_balance = current_balance - ?
|
||||
WHERE id = ?
|
||||
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -323,20 +324,20 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
|
||||
SET current_balance = current_balance - ?
|
||||
WHERE id = ?
|
||||
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -344,13 +345,13 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error {
|
||||
func (s ServiceImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error {
|
||||
if user == nil {
|
||||
return core.ErrUnauthorized
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -362,7 +363,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
UPDATE account
|
||||
SET current_balance = 0
|
||||
WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -371,7 +372,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
UPDATE treasure_chest
|
||||
SET current_balance = 0
|
||||
WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -380,7 +381,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
SELECT *
|
||||
FROM "transaction"
|
||||
WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
if err != nil && !errors.Is(err, core.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -391,10 +392,10 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
}
|
||||
}()
|
||||
|
||||
var transaction types.Transaction
|
||||
var transaction Transaction
|
||||
for rows.Next() {
|
||||
err = rows.StructScan(&transaction)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -405,7 +406,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
SET error = ?
|
||||
WHERE user_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 {
|
||||
return err
|
||||
}
|
||||
@@ -419,7 +420,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
UPDATE account
|
||||
SET current_balance = current_balance + ?
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -429,7 +430,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
UPDATE treasure_chest
|
||||
SET current_balance = current_balance + ?
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -437,7 +438,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -445,7 +446,63 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
|
||||
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 (
|
||||
id uuid.UUID
|
||||
createdAt time.Time
|
||||
@@ -475,7 +532,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
||||
|
||||
if input.AccountId != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -486,9 +543,9 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
||||
}
|
||||
|
||||
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 = db.TransformAndLogDbError(ctx, "transaction validate", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transaction validate", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
|
||||
@@ -501,19 +558,19 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
||||
}
|
||||
|
||||
if input.Party != "" {
|
||||
err = ValidateString(input.Party, "party")
|
||||
err = core.ValidateString(input.Party, "party")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Description != "" {
|
||||
err = ValidateString(input.Description, "description")
|
||||
err = core.ValidateString(input.Description, "description")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
transaction := types.Transaction{
|
||||
transaction := Transaction{
|
||||
Id: id,
|
||||
UserId: userId,
|
||||
|
||||
@@ -536,7 +593,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
|
||||
return &transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) updateErrors(t *types.Transaction) {
|
||||
func (s ServiceImpl) updateErrors(t *Transaction) {
|
||||
errorStr := ""
|
||||
|
||||
switch {
|
||||
@@ -1,13 +1,16 @@
|
||||
package transaction
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
import "spend-sparrow/internal/template/svg"
|
||||
import "spend-sparrow/internal/types"
|
||||
import "spend-sparrow/internal/account"
|
||||
import "github.com/google/uuid"
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"spend-sparrow/internal/account"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/template/svg"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
"time"
|
||||
)
|
||||
|
||||
templ Transaction(items templ.Component, filter types.TransactionItemsFilter, accounts []*account.Account, treasureChests []*types.TreasureChest) {
|
||||
templ TransactionComp(items templ.Component, filter TransactionItemsFilter, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
|
||||
<div class="max-w-6xl mt-10 mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<form
|
||||
@@ -62,7 +65,9 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
|
||||
hx-swap="afterbegin"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -88,7 +93,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
|
||||
</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">
|
||||
for _, transaction := range transactions {
|
||||
@TransactionItem(transaction, accounts, treasureChests)
|
||||
@@ -96,7 +101,7 @@ templ TransactionItems(transactions []*types.Transaction, accounts, treasureChes
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EditTransaction(transaction *types.Transaction, accounts []*account.Account, treasureChests []*types.TreasureChest) {
|
||||
templ EditTransaction(transaction *Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
|
||||
{{
|
||||
var (
|
||||
timestamp time.Time
|
||||
@@ -220,7 +225,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*account.Accoun
|
||||
</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"
|
||||
if transaction.Error != nil {
|
||||
@@ -273,9 +278,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
|
||||
</p>
|
||||
</div>
|
||||
if transaction.Value < 0 {
|
||||
<p class="mr-8 min-w-22 text-right text-red-700">{ types.FormatEuros(transaction.Value) }</p>
|
||||
<p class="mr-8 min-w-22 text-right text-red-700">{ core.FormatEuros(transaction.Value) }</p>
|
||||
} else {
|
||||
<p class="mr-8 w-22 text-right text-green-700">{ types.FormatEuros(transaction.Value) }</p>
|
||||
<p class="mr-8 w-22 text-right text-green-700">{ core.FormatEuros(transaction.Value) }</p>
|
||||
}
|
||||
<button
|
||||
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
|
||||
@@ -1,4 +1,4 @@
|
||||
package types
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -1,44 +1,40 @@
|
||||
package handler
|
||||
package transaction_recurring
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/service"
|
||||
t "spend-sparrow/internal/template/transaction_recurring"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/utils"
|
||||
)
|
||||
|
||||
type TransactionRecurring interface {
|
||||
type Handler interface {
|
||||
Handle(router *http.ServeMux)
|
||||
}
|
||||
|
||||
type TransactionRecurringImpl struct {
|
||||
s service.TransactionRecurring
|
||||
type HandlerImpl struct {
|
||||
s Service
|
||||
r *core.Render
|
||||
}
|
||||
|
||||
func NewTransactionRecurring(s service.TransactionRecurring, r *core.Render) TransactionRecurring {
|
||||
return TransactionRecurringImpl{
|
||||
func NewHandler(s Service, r *core.Render) Handler {
|
||||
return HandlerImpl{
|
||||
s: s,
|
||||
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("POST /transaction-recurring/{id}", h.handleUpdateTransactionRecurring())
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,17 +45,17 @@ func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.Hand
|
||||
}
|
||||
}
|
||||
|
||||
func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
|
||||
func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
input := types.TransactionRecurringInput{
|
||||
input := TransactionRecurringInput{
|
||||
Id: r.PathValue("id"),
|
||||
IntervalMonths: r.FormValue("interval-months"),
|
||||
NextExecution: r.FormValue("next-execution"),
|
||||
@@ -88,13 +84,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
|
||||
}
|
||||
}
|
||||
|
||||
func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
|
||||
func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,11 +108,11 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
|
||||
}
|
||||
}
|
||||
|
||||
func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *auth_types.User, id, accountId, treasureChestId string) {
|
||||
var transactionsRecurring []*types.TransactionRecurring
|
||||
func (h HandlerImpl) renderItems(w http.ResponseWriter, r *http.Request, user *auth_types.User, id, accountId, treasureChestId string) {
|
||||
var transactionsRecurring []*TransactionRecurring
|
||||
var err error
|
||||
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 != "" {
|
||||
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
|
||||
@@ -132,6 +128,6 @@ func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
comp := t.TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
|
||||
comp := TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
|
||||
h.r.Render(r, w, comp)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package transaction_recurring
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"math"
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -17,43 +16,43 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type TransactionRecurring interface {
|
||||
Add(ctx context.Context, user *auth_types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
|
||||
Update(ctx context.Context, user *auth_types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
|
||||
GetAll(ctx context.Context, user *auth_types.User) ([]*types.TransactionRecurring, error)
|
||||
GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*types.TransactionRecurring, error)
|
||||
GetAllByTreasureChest(ctx context.Context, user *auth_types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
|
||||
const (
|
||||
DECIMALS_MULTIPLIER = 100
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
GenerateTransactions(ctx context.Context) error
|
||||
}
|
||||
|
||||
type TransactionRecurringImpl struct {
|
||||
db *sqlx.DB
|
||||
clock core.Clock
|
||||
random core.Random
|
||||
transaction Transaction
|
||||
type ServiceImpl struct {
|
||||
db *sqlx.DB
|
||||
clock core.Clock
|
||||
random core.Random
|
||||
}
|
||||
|
||||
func NewTransactionRecurring(db *sqlx.DB, random core.Random, clock core.Clock, transaction Transaction) TransactionRecurring {
|
||||
return TransactionRecurringImpl{
|
||||
db: db,
|
||||
clock: clock,
|
||||
random: random,
|
||||
transaction: transaction,
|
||||
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||
return ServiceImpl{
|
||||
db: db,
|
||||
clock: clock,
|
||||
random: random,
|
||||
}
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) Add(ctx context.Context,
|
||||
func (s ServiceImpl) Add(ctx context.Context,
|
||||
user *auth_types.User,
|
||||
transactionRecurringInput types.TransactionRecurringInput,
|
||||
) (*types.TransactionRecurring, error) {
|
||||
transactionRecurringInput TransactionRecurringInput,
|
||||
) (*TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,13 +71,13 @@ func (s TransactionRecurringImpl) Add(ctx context.Context,
|
||||
VALUES (:id, :user_id, :interval_months,
|
||||
:next_execution, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`,
|
||||
transactionRecurring)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,10 +85,10 @@ func (s TransactionRecurringImpl) Add(ctx context.Context,
|
||||
return transactionRecurring, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) Update(ctx context.Context,
|
||||
func (s ServiceImpl) Update(ctx context.Context,
|
||||
user *auth_types.User,
|
||||
input types.TransactionRecurringInput,
|
||||
) (*types.TransactionRecurring, error) {
|
||||
input TransactionRecurringInput,
|
||||
) (*TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -100,7 +99,7 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,9 +107,9 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
||||
_ = 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 = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, core.ErrBadRequest)
|
||||
@@ -137,13 +136,13 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,19 +150,19 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
|
||||
return transactionRecurring, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*types.TransactionRecurring, error) {
|
||||
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
|
||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
||||
transactionRecurrings := make([]*TransactionRecurring, 0)
|
||||
err := s.db.SelectContext(ctx, &transactionRecurrings, `
|
||||
SELECT *
|
||||
FROM transaction_recurring
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,7 +170,7 @@ func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *auth_types.U
|
||||
return transactionRecurrings, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*types.TransactionRecurring, error) {
|
||||
func (s ServiceImpl) GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -183,7 +182,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,7 +192,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
|
||||
|
||||
var rowCount int
|
||||
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 errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("account %v not found: %w", accountId, core.ErrBadRequest)
|
||||
@@ -201,7 +200,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
|
||||
return nil, core.ErrInternal
|
||||
}
|
||||
|
||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
||||
transactionRecurrings := make([]*TransactionRecurring, 0)
|
||||
err = tx.SelectContext(ctx, &transactionRecurrings, `
|
||||
SELECT *
|
||||
FROM transaction_recurring
|
||||
@@ -209,13 +208,13 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
|
||||
AND account_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
user.Id, accountUuid)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,10 +222,10 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
|
||||
return transactionRecurrings, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
func (s ServiceImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
user *auth_types.User,
|
||||
treasureChestId string,
|
||||
) ([]*types.TransactionRecurring, error) {
|
||||
) ([]*TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -238,7 +237,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -248,7 +247,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
|
||||
var rowCount int
|
||||
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 errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, core.ErrBadRequest)
|
||||
@@ -256,7 +255,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
return nil, core.ErrInternal
|
||||
}
|
||||
|
||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
||||
transactionRecurrings := make([]*TransactionRecurring, 0)
|
||||
err = tx.SelectContext(ctx, &transactionRecurrings, `
|
||||
SELECT *
|
||||
FROM transaction_recurring
|
||||
@@ -264,13 +263,13 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
AND treasure_chest_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
user.Id, treasureChestUuid)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -278,7 +277,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
|
||||
return transactionRecurrings, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
|
||||
if user == nil {
|
||||
return core.ErrUnauthorized
|
||||
}
|
||||
@@ -289,7 +288,7 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.U
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -297,21 +296,21 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.U
|
||||
_ = 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 = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := tx.ExecContext(ctx, "DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -319,69 +318,13 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.U
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GenerateTransactions(ctx context.Context) error {
|
||||
now := s.clock.Now()
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
recurringTransactions := make([]*types.TransactionRecurring, 0)
|
||||
err = tx.SelectContext(ctx, &recurringTransactions, `
|
||||
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
|
||||
now)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, transactionRecurring := range recurringTransactions {
|
||||
user := &auth_types.User{
|
||||
Id: transactionRecurring.UserId,
|
||||
}
|
||||
transaction := types.Transaction{
|
||||
Timestamp: *transactionRecurring.NextExecution,
|
||||
Party: transactionRecurring.Party,
|
||||
Description: transactionRecurring.Description,
|
||||
|
||||
TreasureChestId: transactionRecurring.TreasureChestId,
|
||||
Value: transactionRecurring.Value,
|
||||
}
|
||||
|
||||
_, err = s.transaction.Add(ctx, tx, user, transaction)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
|
||||
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
|
||||
nextExecution, transactionRecurring.Id, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
func (s ServiceImpl) validateAndEnrichTransactionRecurring(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
oldTransactionRecurring *types.TransactionRecurring,
|
||||
oldTransactionRecurring *TransactionRecurring,
|
||||
userId uuid.UUID,
|
||||
input types.TransactionRecurringInput,
|
||||
) (*types.TransactionRecurring, error) {
|
||||
input TransactionRecurringInput,
|
||||
) (*TransactionRecurring, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
accountUuid *uuid.UUID
|
||||
@@ -422,7 +365,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
}
|
||||
accountUuid = &temp
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -441,9 +384,9 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
return nil, fmt.Errorf("could not parse treasureChestId: %w", core.ErrBadRequest)
|
||||
}
|
||||
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 = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
|
||||
@@ -473,13 +416,13 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
|
||||
|
||||
if input.Party != "" {
|
||||
err = ValidateString(input.Party, "party")
|
||||
err = core.ValidateString(input.Party, "party")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Description != "" {
|
||||
err = ValidateString(input.Description, "description")
|
||||
err = core.ValidateString(input.Description, "description")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -505,7 +448,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
nextExecution = &t
|
||||
}
|
||||
|
||||
transactionRecurring := types.TransactionRecurring{
|
||||
transactionRecurring := TransactionRecurring{
|
||||
Id: id,
|
||||
UserId: userId,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package transaction_recurring
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
import "spend-sparrow/internal/template/svg"
|
||||
import "spend-sparrow/internal/types"
|
||||
import (
|
||||
"fmt"
|
||||
"spend-sparrow/internal/core"
|
||||
"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 -->
|
||||
<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>
|
||||
@@ -27,7 +29,7 @@ templ TransactionRecurringItems(transactionsRecurring []*types.TransactionRecurr
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
|
||||
templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) {
|
||||
<p class="text-gray-600">
|
||||
if transactionRecurring.NextExecution != nil {
|
||||
{ transactionRecurring.NextExecution.Format("2006/01") }
|
||||
@@ -53,9 +55,9 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
|
||||
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
|
||||
</p>
|
||||
if transactionRecurring.Value < 0 {
|
||||
<p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
|
||||
<p class="text-right text-red-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
|
||||
} 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">
|
||||
<button
|
||||
@@ -84,7 +86,7 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
|
||||
templ EditTransactionRecurring(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) {
|
||||
{{
|
||||
var (
|
||||
id string
|
||||
@@ -1,4 +1,4 @@
|
||||
package types
|
||||
package transaction_recurring
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -1,50 +1,47 @@
|
||||
package handler
|
||||
package treasure_chest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/service"
|
||||
tr "spend-sparrow/internal/template/transaction_recurring"
|
||||
t "spend-sparrow/internal/template/treasurechest"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/utils"
|
||||
"spend-sparrow/internal/transaction_recurring"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TreasureChest interface {
|
||||
type Handler interface {
|
||||
Handle(router *http.ServeMux)
|
||||
}
|
||||
|
||||
type TreasureChestImpl struct {
|
||||
s service.TreasureChest
|
||||
transactionRecurring service.TransactionRecurring
|
||||
type HandlerImpl struct {
|
||||
s Service
|
||||
transactionRecurring transaction_recurring.Service
|
||||
r *core.Render
|
||||
}
|
||||
|
||||
func NewTreasureChest(s service.TreasureChest, transactionRecurring service.TransactionRecurring, r *core.Render) TreasureChest {
|
||||
return TreasureChestImpl{
|
||||
func NewHandler(s Service, transactionRecurring transaction_recurring.Service, r *core.Render) Handler {
|
||||
return HandlerImpl{
|
||||
s: s,
|
||||
transactionRecurring: transactionRecurring,
|
||||
r: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (h TreasureChestImpl) Handle(r *http.ServeMux) {
|
||||
r.Handle("GET /treasurechest", h.handleTreasureChestPage())
|
||||
r.Handle("GET /treasurechest/{id}", h.handleTreasureChestItemComp())
|
||||
r.Handle("POST /treasurechest/{id}", h.handleUpdateTreasureChest())
|
||||
r.Handle("DELETE /treasurechest/{id}", h.handleDeleteTreasureChest())
|
||||
func (h HandlerImpl) Handle(r *http.ServeMux) {
|
||||
r.Handle("GET /treasurechest", h.handleHandlerPage())
|
||||
r.Handle("GET /treasurechest/{id}", h.handleHandlerItemComp())
|
||||
r.Handle("POST /treasurechest/{id}", h.handleUpdateHandler())
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,18 +59,18 @@ func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
|
||||
|
||||
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
||||
|
||||
comp := t.TreasureChest(treasureChests, monthlySums)
|
||||
comp := TreasureChestComp(treasureChests, monthlySums)
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,7 +82,7 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "new" {
|
||||
comp := t.EditTreasureChest(nil, treasureChests, nil)
|
||||
comp := EditTreasureChest(nil, treasureChests, nil)
|
||||
h.r.Render(r, w, comp)
|
||||
return
|
||||
}
|
||||
@@ -101,31 +98,31 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
|
||||
core.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
transactionsRec := tr.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
|
||||
transactionsRec := transaction_recurring.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
|
||||
|
||||
var comp templ.Component
|
||||
if r.URL.Query().Get("edit") == "true" {
|
||||
comp = t.EditTreasureChest(treasureChest, treasureChests, transactionsRec)
|
||||
comp = EditTreasureChest(treasureChest, treasureChests, transactionsRec)
|
||||
} else {
|
||||
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
||||
comp = t.TreasureChestItem(treasureChest, monthlySums)
|
||||
comp = TreasureChestItem(treasureChest, monthlySums)
|
||||
}
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
treasureChest *types.TreasureChest
|
||||
treasureChest *treasure_chest_types.TreasureChest
|
||||
err error
|
||||
)
|
||||
id := r.PathValue("id")
|
||||
@@ -151,21 +148,21 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
treasureChests := make([]*types.TreasureChest, 1)
|
||||
treasureChests := make([]*treasure_chest_types.TreasureChest, 1)
|
||||
treasureChests[0] = treasureChest
|
||||
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
|
||||
comp := t.TreasureChestItem(treasureChest, monthlySums)
|
||||
comp := TreasureChestItem(treasureChest, monthlySums)
|
||||
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) {
|
||||
core.UpdateSpan(r)
|
||||
|
||||
user := core.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
core.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,9 +176,9 @@ func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (h TreasureChestImpl) calculateMonthlySums(
|
||||
treasureChests []*types.TreasureChest,
|
||||
transactionsRecurring []*types.TransactionRecurring,
|
||||
func (h HandlerImpl) calculateMonthlySums(
|
||||
treasureChests []*treasure_chest_types.TreasureChest,
|
||||
transactionsRecurring []*transaction_recurring.TransactionRecurring,
|
||||
) map[uuid.UUID]int64 {
|
||||
monthlySums := make(map[uuid.UUID]int64)
|
||||
for _, tc := range treasureChests {
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package treasure_chest
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,36 +8,35 @@ import (
|
||||
"slices"
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/treasure_chest_types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type TreasureChest interface {
|
||||
Add(ctx context.Context, user *auth_types.User, parentId, name string) (*types.TreasureChest, error)
|
||||
Update(ctx context.Context, user *auth_types.User, id, parentId, name string) (*types.TreasureChest, error)
|
||||
Get(ctx context.Context, user *auth_types.User, id string) (*types.TreasureChest, error)
|
||||
GetAll(ctx context.Context, user *auth_types.User) ([]*types.TreasureChest, error)
|
||||
type Service interface {
|
||||
Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error)
|
||||
Update(ctx context.Context, user *auth_types.User, id, parentId, name string) (*treasure_chest_types.TreasureChest, error)
|
||||
Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error)
|
||||
GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error)
|
||||
Delete(ctx context.Context, user *auth_types.User, id string) error
|
||||
}
|
||||
|
||||
type TreasureChestImpl struct {
|
||||
type ServiceImpl struct {
|
||||
db *sqlx.DB
|
||||
clock core.Clock
|
||||
random core.Random
|
||||
}
|
||||
|
||||
func NewTreasureChest(db *sqlx.DB, random core.Random, clock core.Clock) TreasureChest {
|
||||
return TreasureChestImpl{
|
||||
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
|
||||
return ServiceImpl{
|
||||
db: db,
|
||||
clock: clock,
|
||||
random: random,
|
||||
}
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*types.TreasureChest, error) {
|
||||
func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -47,7 +46,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
|
||||
return nil, core.ErrInternal
|
||||
}
|
||||
|
||||
err = ValidateString(name, "name")
|
||||
err = core.ValidateString(name, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -64,7 +63,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
|
||||
parentUuid = &parent.Id
|
||||
}
|
||||
|
||||
treasureChest := &types.TreasureChest{
|
||||
treasureChest := &treasure_chest_types.TreasureChest{
|
||||
Id: newId,
|
||||
ParentId: parentUuid,
|
||||
UserId: user.Id,
|
||||
@@ -82,7 +81,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
|
||||
r, err := s.db.NamedExecContext(ctx, `
|
||||
INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by)
|
||||
VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,11 +89,11 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
|
||||
return treasureChest, nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
|
||||
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, idStr, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
err := ValidateString(name, "name")
|
||||
err := core.ValidateString(name, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,7 +104,7 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,9 +112,9 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
|
||||
_ = 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 = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
|
||||
@@ -131,7 +130,7 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
|
||||
}
|
||||
var childCount int
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,13 +157,13 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -172,7 +171,7 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
|
||||
return treasureChest, nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Get(ctx context.Context, user *auth_types.User, id string) (*types.TreasureChest, error) {
|
||||
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error) {
|
||||
if user == nil {
|
||||
return nil, core.ErrUnauthorized
|
||||
}
|
||||
@@ -182,9 +181,9 @@ func (s TreasureChestImpl) Get(ctx context.Context, user *auth_types.User, id st
|
||||
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
|
||||
}
|
||||
|
||||
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 = db.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
|
||||
@@ -195,22 +194,22 @@ func (s TreasureChestImpl) Get(ctx context.Context, user *auth_types.User, id st
|
||||
return &treasureChest, nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*types.TreasureChest, error) {
|
||||
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error) {
|
||||
if user == nil {
|
||||
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 = db.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sortTreasureChests(treasureChests), nil
|
||||
return SortTreasureChests(treasureChests), nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, idStr string) error {
|
||||
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, idStr string) error {
|
||||
if user == nil {
|
||||
return core.ErrUnauthorized
|
||||
}
|
||||
@@ -221,7 +220,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -231,7 +230,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
|
||||
|
||||
childCount := 0
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -244,7 +243,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
|
||||
err = tx.GetContext(ctx, &transactionsCount,
|
||||
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
|
||||
user.Id, id)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -256,7 +255,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
|
||||
err = tx.GetContext(ctx, &recurringCount, `
|
||||
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
|
||||
user.Id, id)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -265,13 +264,13 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
|
||||
}
|
||||
|
||||
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,12 +278,12 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
||||
func SortTreasureChests(nodes []*treasure_chest_types.TreasureChest) []*treasure_chest_types.TreasureChest {
|
||||
var (
|
||||
roots []*types.TreasureChest
|
||||
roots []*treasure_chest_types.TreasureChest
|
||||
)
|
||||
children := make(map[uuid.UUID][]*types.TreasureChest)
|
||||
result := make([]*types.TreasureChest, 0)
|
||||
children := make(map[uuid.UUID][]*treasure_chest_types.TreasureChest)
|
||||
result := make([]*treasure_chest_types.TreasureChest, 0)
|
||||
|
||||
for _, node := range nodes {
|
||||
if node.ParentId == nil {
|
||||
@@ -294,7 +293,7 @@ func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(roots, func(a, b *types.TreasureChest) int {
|
||||
slices.SortFunc(roots, func(a, b *treasure_chest_types.TreasureChest) int {
|
||||
return compareStrings(a.Name, b.Name)
|
||||
})
|
||||
|
||||
@@ -303,7 +302,7 @@ func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
||||
|
||||
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)
|
||||
})
|
||||
result = append(result, childList...)
|
||||
@@ -1,10 +1,13 @@
|
||||
package treasurechest
|
||||
package treasure_chest
|
||||
|
||||
import "spend-sparrow/internal/template/svg"
|
||||
import "spend-sparrow/internal/types"
|
||||
import "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">
|
||||
<button
|
||||
hx-get="/treasurechest/new"
|
||||
@@ -12,7 +15,9 @@ templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.
|
||||
hx-swap="afterbegin"
|
||||
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
|
||||
</button>
|
||||
<div id="treasurechest-items" class="my-6 flex flex-col">
|
||||
@@ -23,30 +28,30 @@ templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.
|
||||
</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 (
|
||||
id string
|
||||
name string
|
||||
parentId uuid.UUID
|
||||
cancelUrl string
|
||||
)
|
||||
var (
|
||||
id string
|
||||
name string
|
||||
parentId uuid.UUID
|
||||
cancelUrl string
|
||||
)
|
||||
|
||||
indentation := " mt-10"
|
||||
if treasureChest == nil {
|
||||
id = "new"
|
||||
name = ""
|
||||
parentId = uuid.Nil
|
||||
cancelUrl = "/empty"
|
||||
} else {
|
||||
id = treasureChest.Id.String()
|
||||
name = treasureChest.Name
|
||||
if treasureChest.ParentId != nil {
|
||||
parentId = *treasureChest.ParentId
|
||||
indentation = " mt-2 ml-14"
|
||||
indentation := " mt-10"
|
||||
if treasureChest == nil {
|
||||
id = "new"
|
||||
name = ""
|
||||
parentId = uuid.Nil
|
||||
cancelUrl = "/empty"
|
||||
} else {
|
||||
id = treasureChest.Id.String()
|
||||
name = treasureChest.Name
|
||||
if treasureChest.ParentId != nil {
|
||||
parentId = *treasureChest.ParentId
|
||||
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 }>
|
||||
<form
|
||||
@@ -106,7 +111,9 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
|
||||
hx-swap="outerHTML"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -115,30 +122,30 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
|
||||
</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
|
||||
viewTransactions := ""
|
||||
if treasureChest.ParentId != nil {
|
||||
indentation = " mt-2 ml-14"
|
||||
} else {
|
||||
indentation = " mt-10"
|
||||
viewTransactions = "hidden"
|
||||
}
|
||||
var indentation string
|
||||
viewTransactions := ""
|
||||
if treasureChest.ParentId != nil {
|
||||
indentation = " mt-2 ml-14"
|
||||
} else {
|
||||
indentation = " mt-10"
|
||||
viewTransactions = "hidden"
|
||||
}
|
||||
}}
|
||||
<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">
|
||||
<p class="mr-auto">{ treasureChest.Name }</p>
|
||||
<p class="mr-20 text-gray-600">
|
||||
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>
|
||||
if treasureChest.ParentId != nil {
|
||||
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 {
|
||||
<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
|
||||
@@ -177,8 +184,8 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
|
||||
</div>
|
||||
}
|
||||
|
||||
func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.TreasureChest {
|
||||
var result []*types.TreasureChest
|
||||
func filterNoChildNoSelf(nodes []*treasure_chest_types.TreasureChest, selfId string) []*treasure_chest_types.TreasureChest {
|
||||
var result []*treasure_chest_types.TreasureChest
|
||||
|
||||
for _, node := range nodes {
|
||||
if node.ParentId == nil && node.Id.String() != selfId {
|
||||
@@ -1,4 +1,4 @@
|
||||
package types
|
||||
package treasure_chest_types
|
||||
|
||||
import (
|
||||
"time"
|
||||
7
main.go
7
main.go
@@ -22,8 +22,11 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
|
||||
otelsql.WithAttributes(semconv.DBSystemSqlite))
|
||||
db, err := otelsqlx.Open(
|
||||
"sqlite3",
|
||||
"./data/spend-sparrow.db?_journal_mode=WAL",
|
||||
otelsql.WithAttributes(semconv.DBSystemSqlite),
|
||||
)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
|
||||
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;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
menuButton.addEventListener("click", function (e) {
|
||||
menuButton.addEventListener("click", function() {
|
||||
menu.showModal();
|
||||
});
|
||||
menuButtonClose.addEventListener("click", function (e) {
|
||||
menuButtonClose.addEventListener("click", function() {
|
||||
menu.close();
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ function updateTime() {
|
||||
document.querySelectorAll(".datetime").forEach((el) => {
|
||||
if (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 newDate = value.includes("UTC") ? new Date(value) : value;
|
||||
el.valueAsDate = newDate;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
const scrollToTop = function() {
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/authentication"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/db"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,7 +28,7 @@ func setupDb(t *testing.T) *sqlx.DB {
|
||||
}
|
||||
})
|
||||
|
||||
err = db.RunMigrations(context.Background(), d, "../")
|
||||
err = core.RunMigrations(context.Background(), d, "../")
|
||||
if err != nil {
|
||||
t.Fatalf("Error running migrations: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"spend-sparrow/internal/auth_types"
|
||||
"spend-sparrow/internal/authentication"
|
||||
"spend-sparrow/internal/core"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/mocks"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -18,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
settings = types.Settings{
|
||||
settings = core.Settings{
|
||||
Port: "",
|
||||
BaseUrl: "",
|
||||
Environment: "test",
|
||||
|
||||
Reference in New Issue
Block a user