Compare commits

1 Commits

Author SHA1 Message Date
9f9f725055 feat(transaction): #101 replace "note" with "party" and "description"
Some checks failed
Build Docker Image / Build-Docker-Image (push) Has been cancelled
2025-05-17 22:06:52 +02:00
130 changed files with 4665 additions and 7325 deletions

View File

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

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- 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

3
.gitignore vendored
View File

@@ -25,13 +25,12 @@ go.work.sum
# env file
.env
data/
*.db
secrets/
node_modules/
static/css/tailwind.css
static/js/htmx.min.js
static/js/echarts.min.js
tmp/
mocks/*

View File

@@ -1,32 +0,0 @@
version: '2'
linters:
default: all
disable:
- wsl
- wrapcheck
- varnamelen
- revive # should probably be enabled
- nlreturn
- mnd # should probably be enabled
- lll # should probably be enabled
- ireturn # should probably be enabled
- interfacebloat
- iface
- goconst # should probably be enabled
- gocognit # should probably be enabled
- gochecknoglobals # should probably be enabled
- funlen
- maintidx
- exhaustruct
- dupword # should probably be enabled
- dupl # should probably be enabled
- depguard
- cyclop
- contextcheck
- bodyclose # i don't care in the tests, the implementation itself doesn't do http requests
- wsl_v5
- noinlineerr
- unqueryvet
settings:
nestif:
min-complexity: 6

View File

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

1
.nvmrc
View File

@@ -1 +0,0 @@
24.11.0

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.3@sha256:7e3cbcd2f6af1bebb937462ec29f77ce28b406081af509afed158fa8721f11af AS builder_go
FROM golang:1.24.3@sha256:86b4cff66e04d41821a17cea30c1031ed53e2635e2be99ae0b4a7d69336b5063 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.11.0@sha256:e5bbac0e9b8a6e3b96a86a82bbbcf4c533a879694fd613ed616bae5116f6f243 AS builder_node
FROM node:22.15.1@sha256:e558507eb799e3a76fcdaaee5e48dce1a00aebc85892128a9fca59f63bd49511 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.1@sha256:01a723bf5bfb21b9dda0c9a33e0538106e4d02cce8f557e118dd61259553d598
FROM debian:12.10@sha256:264982ff4d18000fa74540837e2c43ca5137a53a83f8f62c7b3803c0f0bdcd56
WORKDIR /spend-sparrow
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
COPY migration ./migration

View File

@@ -3,15 +3,6 @@
SpendSparrow is a web app to keep track of expenses and income. It is very opinionated by keeping an keen eye on disciplin of it's users. Every Expense needs to be mapped to a Piggy Bank. For emergencies, funds can be moved between Piggy Banks.
## Prerequisites
```bash
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
```
## Design priciples
The State of the application can always be calculated on the fly. Even though it is not an Event Streaming Application, it is still important to be able to recalculate historic data.
@@ -19,4 +10,3 @@ It may be applicable to do some sort of monthly snapshots to speed up calculatio
This applications uses as little dependencies as feasible, especially on the front end.

View File

@@ -1,188 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="logo-inkscape.svg"
inkscape:export-filename="static/logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="2.5914723"
inkscape:cx="385.10927"
inkscape:cy="275.712"
inkscape:window-width="2252"
inkscape:window-height="1450"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer2" />
<defs
id="defs1">
<rect
x="115.37843"
y="80.263254"
width="470.38898"
height="197.18521"
id="rect2" />
<rect
x="175.18448"
y="463.06726"
width="253.29221"
height="303.50433"
id="rect6" />
</defs>
<g
inkscape:label="Faviocon"
inkscape:groupmode="layer"
id="layer1">
<path
d="m 59.240389,97.978247 c 1.775354,-0.394229 4.087813,-2.156354 4.439709,-3.024187 0.206375,-0.508 -0.822855,-1.30175 -1.098021,-1.621896 -0.629709,-0.73025 -0.375709,-1.090083 -0.132292,-1.960562 0.277813,-0.989542 -0.381,-2.082271 -1.314979,-2.510896 -0.933979,-0.428625 -2.050521,-0.293688 -2.989792,0.124354 -0.939271,0.418042 -1.740958,1.090083 -2.526771,1.751542 -0.574145,-0.36248 -1.489604,-1.963209 -2.97127,-0.923396 -1.023938,0.717021 -1.116542,2.278062 -0.98425,3.52425 0.309562,2.876021 1.018645,4.368271 2.354791,4.770437 1.688042,0.508 3.556,0.240771 5.222875,-0.129646"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3"
inkscape:export-filename="static/favicon.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<path
d="m 62.124348,89.704727 c -0.224896,3.876145 -4.005792,6.447895 -5.799667,7.580312 l 1.164167,1.000125 c 0,0 0.738187,0.01588 1.748895,-0.306917 1.733021,-0.550333 4.265084,-2.106083 4.439709,-3.024187 0.256646,-1.336146 -1.113896,-1.045104 -1.423459,-2.100792 -0.161395,-0.558271 0.785813,-1.613958 -0.129645,-3.148541 m -6.503459,1.03452 c 0,0 -0.674687,-0.690562 -1.17475,-1.005416 -0.248708,0.468312 -0.425979,0.976312 -0.513291,1.500187 -0.156105,0.92075 0,2.227792 0.36777,3.201459 0.05821,0.150812 0.275167,0.127 0.29898,-0.03175 0.3175,-2.092855 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6" />
<path
d="m 50.906014,97.636935 c 0,0 -8.252354,0.891646 -11.975042,8.056565 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490104,-1.30175 6.863292,-4.0005 6.606646,-6.60664 -0.373062,-3.80471 -3.907896,-6.14363 -3.907896,-6.14363 0,0 0.140229,-4.699 -3.505729,-7.749647 -3.235854,-2.709333 -7.289271,-1.486958 -7.289271,-1.486958"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2" />
<path
d="m 60.042077,103.11381 c 0.280458,-0.19314 1.222375,0.14023 1.486958,1.98438 0.129646,0.90223 0.169333,1.77535 0.169333,1.77535 0,0 -1.11125,-0.99748 -1.47902,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="82.355011"
y="90.66716"
id="text4-9"
inkscape:label="$"
transform="rotate(20.578693)"><tspan
sodipodi:role="line"
id="tspan4-2"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="82.355011"
y="90.66716">$</tspan></text>
</g>
<g
inkscape:label="Logo"
inkscape:groupmode="layer"
id="g7"
transform="translate(1.4293676,-48.496402)">
<g
id="g8"
inkscape:label="Favicon"
transform="translate(-38.797122,-28.178962)"
inkscape:export-filename="../static/logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<path
d="m 98.874384,115.02659 c 1.775356,-0.39423 4.087816,-2.15635 4.439706,-3.02419 0.20638,-0.508 -0.82285,-1.30175 -1.09802,-1.62189 -0.62971,-0.73025 -0.37571,-1.09009 -0.13229,-1.96057 0.27781,-0.98954 -0.381,-2.08227 -1.31498,-2.51089 -0.933978,-0.42863 -2.05052,-0.29369 -2.989791,0.12435 -0.939271,0.41804 -1.740958,1.09009 -2.526771,1.75154 -0.574145,-0.36248 -1.489604,-1.96321 -2.97127,-0.92339 -1.023938,0.71702 -1.116542,2.27806 -0.98425,3.52425 0.309562,2.87602 1.018645,4.36827 2.354791,4.77043 1.688042,0.508 3.556,0.24078 5.222875,-0.12964"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3-3" />
<path
d="m 101.75834,106.75307 c -0.22489,3.87614 -4.005789,6.44789 -5.799664,7.58031 l 1.164167,1.00013 c 0,0 0.738187,0.0159 1.748895,-0.30692 1.733022,-0.55033 4.265082,-2.10608 4.439712,-3.02419 0.25664,-1.33614 -1.1139,-1.0451 -1.42346,-2.10079 -0.1614,-0.55827 0.78581,-1.61396 -0.12965,-3.14854 m -6.503456,1.03452 c 0,0 -0.674687,-0.69056 -1.17475,-1.00542 -0.248708,0.46832 -0.425979,0.97632 -0.513291,1.50019 -0.156105,0.92075 0,2.22779 0.36777,3.20146 0.05821,0.15081 0.275167,0.127 0.29898,-0.0317 0.3175,-2.09286 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6-6" />
<path
d="m 90.540009,114.68528 c 0,0 -8.252354,0.89164 -11.975042,8.05656 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490101,-1.30175 6.863291,-4.0005 6.606641,-6.60664 -0.37306,-3.80471 -3.90789,-6.14363 -3.90789,-6.14363 0,0 0.14023,-4.699 -3.50573,-7.74964 -3.235854,-2.70934 -7.289271,-1.48696 -7.289271,-1.48696"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0-1" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6-2" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2-9" />
<path
d="m 99.676072,120.16215 c 0.280458,-0.19314 1.222378,0.14023 1.486958,1.98438 0.12965,0.90223 0.16933,1.77535 0.16933,1.77535 0,0 -1.11125,-0.99748 -1.479017,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6-3" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="125.45235"
y="92.696564"
id="text4-9-1"
inkscape:label="$"
transform="rotate(20.578693)"><tspan
sodipodi:role="line"
id="tspan4-2-9"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="125.45235"
y="92.696564">$</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Text"
transform="translate(-1.4293676,48.496402)">
<text
xml:space="preserve"
style="font-size:17.6389px;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="57.635151"
y="55.655094"
id="text1"
inkscape:label="SpendSparrow"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:17.6389px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:-0.529167px;fill:#4d4d4d;stroke:none;stroke-width:0.264583"
x="57.635151"
y="55.655094">pendSparrow</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#1a1a1a;stroke-width:0.264583"
x="93.314896"
y="91.227318"
id="text5"><tspan
sodipodi:role="line"
id="tspan5"
style="stroke-width:0.264583"
x="93.314896"
y="91.227318" /></text>
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text6"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:74.6667px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-2px;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect6);display:inline;fill:#1a1a1a;stroke:none" />
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text2"
style="fill:#4d4d4d;text-orientation:auto;-inkscape-font-specification:'Pirata One, Normal';font-family:'Pirata One';font-size:74.66666667px;letter-spacing:-2px;text-align:start;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect2)" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

417
db/auth.go Normal file
View File

@@ -0,0 +1,417 @@
package db
import (
"spend-sparrow/log"
"spend-sparrow/types"
"database/sql"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Auth interface {
InsertUser(user *types.User) error
UpdateUser(user *types.User) error
GetUserByEmail(email string) (*types.User, error)
GetUser(userId uuid.UUID) (*types.User, error)
DeleteUser(userId uuid.UUID) error
InsertToken(token *types.Token) error
GetToken(token string) (*types.Token, error)
GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error)
DeleteToken(token string) error
InsertSession(session *types.Session) error
GetSession(sessionId string) (*types.Session, error)
GetSessions(userId uuid.UUID) ([]*types.Session, error)
DeleteSession(sessionId string) error
DeleteOldSessions(userId uuid.UUID) error
}
type AuthSqlite struct {
db *sqlx.DB
}
func NewAuthSqlite(db *sqlx.DB) *AuthSqlite {
return &AuthSqlite{db: db}
}
func (db AuthSqlite) InsertUser(user *types.User) error {
_, err := db.db.Exec(`
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
user.Id, user.Email, user.EmailVerified, user.EmailVerifiedAt, user.IsAdmin, user.Password, user.Salt, user.CreateAt)
if err != nil {
if strings.Contains(err.Error(), "email") {
return ErrAlreadyExists
}
log.Error("SQL error InsertUser: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) UpdateUser(user *types.User) error {
_, err := db.db.Exec(`
UPDATE user
SET email_verified = ?, email_verified_at = ?, password = ?
WHERE user_id = ?`,
user.EmailVerified, user.EmailVerifiedAt, user.Password, user.Id)
if err != nil {
log.Error("SQL error UpdateUser: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
var (
userId uuid.UUID
emailVerified bool
emailVerifiedAt *time.Time
isAdmin bool
password []byte
salt []byte
createdAt time.Time
)
err := db.db.QueryRow(`
SELECT user_id, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
} else {
log.Error("SQL error GetUser: %v", err)
return nil, types.ErrInternal
}
}
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
var (
email string
emailVerified bool
emailVerifiedAt *time.Time
isAdmin bool
password []byte
salt []byte
createdAt time.Time
)
err := db.db.QueryRow(`
SELECT email, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
} else {
log.Error("SQL error GetUser %v", err)
return nil, types.ErrInternal
}
}
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
tx, err := db.db.Begin()
if err != nil {
log.Error("Could not start transaction: %v", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM account WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete accounts: %v", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM token WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete user tokens: %v", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM session WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete sessions: %v", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM user WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete user: %v", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM treasure_chest WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete user: %v", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM \"transaction\" WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete user: %v", err)
return types.ErrInternal
}
err = tx.Commit()
if err != nil {
log.Error("Could not commit transaction: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertToken(token *types.Token) error {
_, err := db.db.Exec(`
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
if err != nil {
log.Error("Could not insert token: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetToken(token string) (*types.Token, error) {
var (
userId uuid.UUID
sessionId string
tokenType types.TokenType
createdAtStr string
expiresAtStr string
createdAt time.Time
expiresAt time.Time
)
err := db.db.QueryRow(`
SELECT user_id, session_id, type, created_at, expires_at
FROM token
WHERE token = ?`, token).Scan(&userId, &sessionId, &tokenType, &createdAtStr, &expiresAtStr)
if err != nil {
if err == sql.ErrNoRows {
log.Info("Token '%v' not found", token)
return nil, ErrNotFound
} else {
log.Error("Could not get token: %v", err)
return nil, types.ErrInternal
}
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
log.Error("Could not parse token.created_at: %v", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
log.Error("Could not parse token.expires_at: %v", err)
return nil, types.ErrInternal
}
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
SELECT token, created_at, expires_at
FROM token
WHERE user_id = ?
AND type = ?`, userId, tokenType)
if err != nil {
log.Error("Could not get token: %v", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(query, userId, "", tokenType)
}
func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
SELECT token, created_at, expires_at
FROM token
WHERE session_id = ?
AND type = ?`, sessionId, tokenType)
if err != nil {
log.Error("Could not get token: %v", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(query, uuid.Nil, sessionId, tokenType)
}
func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
var tokens []*types.Token
hasRows := false
for query.Next() {
hasRows = true
var (
token string
createdAtStr string
expiresAtStr string
createdAt time.Time
expiresAt time.Time
)
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
if err != nil {
log.Error("Could not scan token: %v", err)
return nil, types.ErrInternal
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
log.Error("Could not parse token.created_at: %v", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
log.Error("Could not parse token.expires_at: %v", err)
return nil, types.ErrInternal
}
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
}
if !hasRows {
return nil, ErrNotFound
}
return tokens, nil
}
func (db AuthSqlite) DeleteToken(token string) error {
_, err := db.db.Exec("DELETE FROM token WHERE token = ?", token)
if err != nil {
log.Error("Could not delete token: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertSession(session *types.Session) error {
_, err := db.db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
if err != nil {
log.Error("Could not insert new session %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetSession(sessionId string) (*types.Session, error) {
var (
userId uuid.UUID
createdAt time.Time
expiresAt time.Time
)
err := db.db.QueryRow(`
SELECT user_id, created_at, expires_at
FROM session
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
if err != nil {
log.Warn("Session \"%s\" not found: %v", sessionId, err)
return nil, ErrNotFound
}
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetSessions(userId uuid.UUID) ([]*types.Session, error) {
sessions, err := db.db.Query(`
SELECT session_id, created_at, expires_at
FROM session
WHERE user_id = ?`, userId)
if err != nil {
log.Error("Could not get sessions: %v", err)
return nil, types.ErrInternal
}
var result []*types.Session
for sessions.Next() {
var (
sessionId string
createdAt time.Time
expiresAt time.Time
)
err := sessions.Scan(&sessionId, &createdAt, &expiresAt)
if err != nil {
log.Error("Could not scan session: %v", err)
return nil, types.ErrInternal
}
session := types.NewSession(sessionId, userId, createdAt, expiresAt)
result = append(result, session)
}
return result, nil
}
func (db AuthSqlite) DeleteOldSessions(userId uuid.UUID) error {
_, err := db.db.Exec(`
DELETE FROM session
WHERE expires_at < datetime('now')
AND user_id = ?`, userId)
if err != nil {
log.Error("Could not delete old sessions: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) DeleteSession(sessionId string) error {
if sessionId != "" {
_, err := db.db.Exec("DELETE FROM session WHERE session_id = ?", sessionId)
if err != nil {
log.Error("Could not delete session: %v", err)
return types.ErrInternal
}
}
return nil
}

View File

@@ -1,38 +1,33 @@
package test_test
package db
import (
"context"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"spend-sparrow/types"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupDb(t *testing.T) *sqlx.DB {
t.Helper()
d, err := sqlx.Open("sqlite3", ":memory:")
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Error opening database: %v", err)
}
t.Cleanup(func() {
err := d.Close()
err := db.Close()
if err != nil {
panic(err)
}
})
err = db.RunMigrations(context.Background(), d, "../")
err = RunMigrations(db, "../")
if err != nil {
t.Fatalf("Error running migrations: %v", err)
}
return d
return db
}
func TestUser(t *testing.T) {
@@ -40,60 +35,60 @@ func TestUser(t *testing.T) {
t.Run("should insert and get the same", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expected := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(context.Background(), expected)
require.NoError(t, err)
err := underTest.InsertUser(expected)
assert.Nil(t, err)
actual, err := underTest.GetUser(context.Background(), expected.Id)
require.NoError(t, err)
actual, err := underTest.GetUser(expected.Id)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
actual, err = underTest.GetUserByEmail(context.Background(), expected.Email)
require.NoError(t, err)
actual, err = underTest.GetUserByEmail(expected.Email)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
})
t.Run("should return ErrNotFound", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
_, err := underTest.GetUserByEmail(context.Background(), "nonExistentEmail")
assert.Equal(t, db.ErrNotFound, err)
_, err := underTest.GetUserByEmail("nonExistentEmail")
assert.Equal(t, ErrNotFound, err)
})
t.Run("should return ErrUserExist", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(context.Background(), user)
require.NoError(t, err)
err := underTest.InsertUser(user)
assert.Nil(t, err)
err = underTest.InsertUser(context.Background(), user)
assert.Equal(t, db.ErrAlreadyExists, err)
err = underTest.InsertUser(user)
assert.Equal(t, ErrAlreadyExists, err)
})
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(context.Background(), user)
err := underTest.InsertUser(user)
assert.Equal(t, types.ErrInternal, err)
})
}
@@ -103,37 +98,37 @@ func TestToken(t *testing.T) {
t.Run("should insert and get the same", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour)
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(context.Background(), expected)
require.NoError(t, err)
err := underTest.InsertToken(expected)
assert.Nil(t, err)
actual, err := underTest.GetToken(context.Background(), expected.Token)
require.NoError(t, err)
actual, err := underTest.GetToken(expected.Token)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
expected.SessionId = ""
actuals, err := underTest.GetTokensByUserIdAndType(context.Background(), expected.UserId, expected.Type)
require.NoError(t, err)
actuals, err := underTest.GetTokensByUserIdAndType(expected.UserId, expected.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected}, actuals)
expected.SessionId = "sessionId"
expected.UserId = uuid.Nil
actuals, err = underTest.GetTokensBySessionIdAndType(context.Background(), expected.SessionId, expected.Type)
require.NoError(t, err)
actuals, err = underTest.GetTokensBySessionIdAndType(expected.SessionId, expected.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected}, actuals)
})
t.Run("should insert and return multiple tokens", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour)
@@ -141,66 +136,67 @@ func TestToken(t *testing.T) {
expected1 := types.NewToken(userId, "sessionId", "token1", types.TokenTypeCsrf, createAt, expiresAt)
expected2 := types.NewToken(userId, "sessionId", "token2", types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(context.Background(), expected1)
require.NoError(t, err)
err = underTest.InsertToken(context.Background(), expected2)
require.NoError(t, err)
err := underTest.InsertToken(expected1)
assert.Nil(t, err)
err = underTest.InsertToken(expected2)
assert.Nil(t, err)
expected1.UserId = uuid.Nil
expected2.UserId = uuid.Nil
actuals, err := underTest.GetTokensBySessionIdAndType(context.Background(), expected1.SessionId, expected1.Type)
require.NoError(t, err)
actuals, err := underTest.GetTokensBySessionIdAndType(expected1.SessionId, expected1.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
expected1.SessionId = ""
expected2.SessionId = ""
expected1.UserId = userId
expected2.UserId = userId
actuals, err = underTest.GetTokensByUserIdAndType(context.Background(), userId, expected1.Type)
require.NoError(t, err)
actuals, err = underTest.GetTokensByUserIdAndType(userId, expected1.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
})
t.Run("should return ErrNotFound", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
_, err := underTest.GetToken(context.Background(), "nonExistent")
assert.Equal(t, db.ErrNotFound, err)
_, err := underTest.GetToken("nonExistent")
assert.Equal(t, ErrNotFound, err)
_, err = underTest.GetTokensByUserIdAndType(context.Background(), uuid.New(), types.TokenTypeEmailVerify)
assert.Equal(t, db.ErrNotFound, err)
_, err = underTest.GetTokensByUserIdAndType(uuid.New(), types.TokenTypeEmailVerify)
assert.Equal(t, ErrNotFound, err)
_, err = underTest.GetTokensBySessionIdAndType(context.Background(), "sessionId", types.TokenTypeEmailVerify)
assert.Equal(t, db.ErrNotFound, err)
_, err = underTest.GetTokensBySessionIdAndType("sessionId", types.TokenTypeEmailVerify)
assert.Equal(t, ErrNotFound, err)
})
t.Run("should return ErrAlreadyExists", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(context.Background(), user)
require.NoError(t, err)
err := underTest.InsertUser(user)
assert.Nil(t, err)
err = underTest.InsertUser(context.Background(), user)
assert.Equal(t, db.ErrAlreadyExists, err)
err = underTest.InsertUser(user)
assert.Equal(t, ErrAlreadyExists, err)
})
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
t.Parallel()
d := setupDb(t)
db := setupDb(t)
underTest := db.NewAuthSqlite(d)
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(context.Background(), user)
err := underTest.InsertUser(user)
assert.Equal(t, types.ErrInternal, err)
})
}

View File

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

40
db/migration.go Normal file
View File

@@ -0,0 +1,40 @@
package db
import (
"spend-sparrow/log"
"spend-sparrow/types"
"errors"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jmoiron/sqlx"
)
func RunMigrations(db *sqlx.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil {
log.Error("Could not create Migration instance: %v", err)
return types.ErrInternal
}
m, err := migrate.NewWithDatabaseInstance(
"file://"+pathPrefix+"migration/",
"",
driver)
if err != nil {
log.Error("Could not create migrations instance: %v", err)
return types.ErrInternal
}
err = m.Up()
if err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
log.Error("Could not run migrations: %v", err)
return types.ErrInternal
}
}
return nil
}

6
dev.sh
View File

@@ -1,9 +1,3 @@
#!/bin/sh
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 ." &
npm run watch

54
go.mod
View File

@@ -1,54 +1,36 @@
module spend-sparrow
go 1.24.0
go 1.23.0
toolchain go1.25.3
toolchain go1.24.3
require (
github.com/a-h/templ v0.3.960
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/a-h/templ v0.3.865
github.com/golang-migrate/migrate/v4 v4.18.3
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/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
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/prometheus/client_golang v1.22.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

117
go.sum
View File

@@ -1,30 +1,21 @@
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/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/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A=
github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -34,79 +25,47 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
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-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -2,11 +2,11 @@ package handler
import (
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
t "spend-sparrow/internal/template/account"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
t "spend-sparrow/template/account"
"spend-sparrow/types"
"spend-sparrow/utils"
"github.com/a-h/templ"
)
@@ -17,12 +17,14 @@ type Account interface {
type AccountImpl struct {
s service.Account
a service.Auth
r *Render
}
func NewAccount(s service.Account, r *Render) Account {
func NewAccount(s service.Account, a service.Auth, r *Render) Account {
return AccountImpl{
s: s,
a: a,
r: r,
}
}
@@ -36,15 +38,13 @@ func (h AccountImpl) Handle(r *http.ServeMux) {
func (h AccountImpl) handleAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
accounts, err := h.s.GetAll(r.Context(), user)
accounts, err := h.s.GetAll(user)
if err != nil {
handleError(w, r, err)
return
@@ -57,8 +57,6 @@ func (h AccountImpl) handleAccountPage() http.HandlerFunc {
func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -72,7 +70,7 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
return
}
account, err := h.s.Get(r.Context(), user, id)
account, err := h.s.Get(user, id)
if err != nil {
handleError(w, r, err)
return
@@ -90,8 +88,6 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -105,13 +101,13 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
id := r.PathValue("id")
name := r.FormValue("name")
if id == "new" {
account, err = h.s.Add(r.Context(), user, name)
account, err = h.s.Add(user, name)
if err != nil {
handleError(w, r, err)
return
}
} else {
account, err = h.s.UpdateName(r.Context(), user, id, name)
account, err = h.s.UpdateName(user, id, name)
if err != nil {
handleError(w, r, err)
return
@@ -125,8 +121,6 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -135,7 +129,7 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
id := r.PathValue("id")
err := h.s.Delete(r.Context(), user, id)
err := h.s.Delete(user, id)
if err != nil {
handleError(w, r, err)
return

View File

@@ -1,15 +1,16 @@
package handler
import (
"spend-sparrow/handler/middleware"
"spend-sparrow/log"
"spend-sparrow/service"
"spend-sparrow/template/auth"
"spend-sparrow/types"
"spend-sparrow/utils"
"errors"
"log/slog"
"net/http"
"net/url"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template/auth"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"time"
)
@@ -58,8 +59,6 @@ var (
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user != nil {
if !user.EmailVerified {
@@ -78,14 +77,13 @@ func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
session := middleware.GetSession(r)
email := r.FormValue("email")
password := r.FormValue("password")
session, user, err := handler.service.SignIn(r.Context(), session, email, password)
session, user, err := handler.service.SignIn(session, email, password)
if err != nil {
return nil, err
}
@@ -97,10 +95,10 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
})
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
if err == service.ErrInvalidCredentials {
utils.TriggerToastWithStatus(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
utils.TriggerToastWithStatus(w, r, "error", "An error occurred", http.StatusInternalServerError)
}
return
}
@@ -115,8 +113,6 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user != nil {
@@ -135,8 +131,6 @@ func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -155,30 +149,27 @@ func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
go handler.service.SendVerificationMail(user.Id, user.Email)
_, err := w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
if err != nil {
slog.ErrorContext(r.Context(), "Could not write response", "err", err)
log.Error("Could not write response: %v", err)
}
}
}
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
token := r.URL.Query().Get("token")
err := handler.service.VerifyUserEmail(r.Context(), token)
err := handler.service.VerifyUserEmail(token)
isVerified := err == nil
comp := auth.VerifyResponseComp(isVerified)
@@ -196,50 +187,45 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
var email = r.FormValue("email")
var password = r.FormValue("password")
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
slog.InfoContext(r.Context(), "signing up", "email", email)
user, err := handler.service.SignUp(r.Context(), email, password)
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
log.Info("Signing up %v", email)
user, err := handler.service.SignUp(email, password)
if err != nil {
return nil, err
}
slog.InfoContext(r.Context(), "Sending verification email", "to", user.Email)
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
log.Info("Sending verification email to %v", user.Email)
go handler.service.SendVerificationMail(user.Id, user.Email)
return nil, nil
})
if err != nil {
switch {
case errors.Is(err, types.ErrInternal):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
if errors.Is(err, types.ErrInternal) {
utils.TriggerToastWithStatus(w, r, "error", "An error occurred", http.StatusInternalServerError)
return
case errors.Is(err, service.ErrInvalidEmail):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
} else if errors.Is(err, service.ErrInvalidEmail) {
utils.TriggerToastWithStatus(w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return
case errors.Is(err, service.ErrInvalidPassword):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
} else if errors.Is(err, service.ErrInvalidPassword) {
utils.TriggerToastWithStatus(w, r, "error", service.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)
utils.TriggerToastWithStatus(w, r, "success", "An activation link has been send to your email", http.StatusOK)
}
}
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
session := middleware.GetSession(r)
if session != nil {
err := handler.service.SignOut(r.Context(), session.Id)
err := handler.service.SignOut(session.Id)
if err != nil {
http.Error(w, "An error occurred", http.StatusInternalServerError)
return
@@ -263,8 +249,6 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -278,8 +262,6 @@ func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -288,12 +270,12 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
password := r.FormValue("password")
err := handler.service.DeleteAccount(r.Context(), user, password)
err := handler.service.DeleteAccount(user, password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
if err == service.ErrInvalidCredentials {
utils.TriggerToastWithStatus(w, r, "error", "Password not correct", http.StatusBadRequest)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
return
}
@@ -304,7 +286,6 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
isPasswordReset := r.URL.Query().Has("token")
@@ -322,31 +303,29 @@ func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
session := middleware.GetSession(r)
user := middleware.GetUser(r)
if session == nil || user == nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
utils.TriggerToastWithStatus(w, r, "error", "Unathorized", http.StatusUnauthorized)
return
}
currPass := r.FormValue("current-password")
newPass := r.FormValue("new-password")
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
utils.TriggerToastWithStatus(w, r, "error", "Password not correct", http.StatusBadRequest)
return
}
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
utils.TriggerToastWithStatus(w, r, "success", "Password changed", http.StatusOK)
}
}
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user != nil {
@@ -361,46 +340,43 @@ func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
email := r.FormValue("email")
if email == "" {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
utils.TriggerToastWithStatus(w, r, "error", "Please enter an email", http.StatusBadRequest)
return
}
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
err := handler.service.SendForgotPasswordMail(r.Context(), email)
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
err := handler.service.SendForgotPasswordMail(email)
return nil, err
})
if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
utils.TriggerToastWithStatus(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)
utils.TriggerToastWithStatus(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
}
}
}
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
if err != nil {
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
log.Error("Could not get current URL: %v", err)
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
token := pageUrl.Query().Get("token")
newPass := r.FormValue("new-password")
err = handler.service.ForgotPassword(r.Context(), token, newPass)
err = handler.service.ForgotPassword(token, newPass)
if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
utils.TriggerToastWithStatus(w, r, "error", err.Error(), http.StatusBadRequest)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
utils.TriggerToastWithStatus(w, r, "success", "Password changed", http.StatusOK)
}
}
}

34
handler/error.go Normal file
View File

@@ -0,0 +1,34 @@
package handler
import (
"errors"
"net/http"
"spend-sparrow/db"
"spend-sparrow/service"
"spend-sparrow/utils"
"strings"
)
func handleError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, service.ErrUnauthorized) {
utils.TriggerToastWithStatus(w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
return
} else if errors.Is(err, service.ErrBadRequest) {
utils.TriggerToastWithStatus(w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
return
} else if errors.Is(err, db.ErrNotFound) {
utils.TriggerToastWithStatus(w, r, "error", extractErrorMessage(err), http.StatusNotFound)
return
}
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
func extractErrorMessage(err error) string {
errMsg := err.Error()
if errMsg == "" {
return ""
}
return strings.SplitN(errMsg, ":", 2)[0]
}

View File

@@ -3,10 +3,9 @@ package middleware
import (
"context"
"net/http"
"strings"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
"spend-sparrow/service"
"spend-sparrow/types"
)
type ContextKey string
@@ -17,21 +16,15 @@ var UserKey ContextKey = "user"
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if strings.Contains(r.URL.Path, "/static/") {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
sessionId := getSessionID(r)
session, user, _ := service.SignInSession(r.Context(), sessionId)
session, user, _ := service.SignInSession(sessionId)
var err error
// Always sign in anonymous
// This way, we can always generate csrf tokens
if session == nil {
session, err = service.SignInAnonymous(r.Context())
session, err = service.SignInAnonymous()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
@@ -41,6 +34,7 @@ func Authenticate(service service.Auth) func(http.Handler) http.Handler {
http.SetCookie(w, &cookie)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserKey, user)
ctx = context.WithValue(ctx, SessionKey, session)
@@ -55,12 +49,7 @@ func GetUser(r *http.Request) *types.User {
return nil
}
user, ok := obj.(*types.User)
if !ok {
return nil
}
return user
return obj.(*types.User)
}
func GetSession(r *http.Request) *types.Session {
@@ -69,12 +58,7 @@ func GetSession(r *http.Request) *types.Session {
return nil
}
session, ok := obj.(*types.Session)
if !ok {
return nil
}
return session
return obj.(*types.Session)
}
func getSessionID(r *http.Request) string {

View File

@@ -3,14 +3,10 @@ package middleware
import (
"net/http"
"strings"
"go.opentelemetry.io/otel"
)
func CacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
counter, _ := otel.Meter("").Int64Counter("spend.sparrow.test")
counter.Add(r.Context(), 1)
shouldCache := strings.HasPrefix(r.URL.Path, "/static")

View File

@@ -0,0 +1,70 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"spend-sparrow/log"
"spend-sparrow/service"
"spend-sparrow/types"
"spend-sparrow/utils"
)
type csrfResponseWriter struct {
http.ResponseWriter
auth service.Auth
session *types.Session
}
func newCsrfResponseWriter(w http.ResponseWriter, auth service.Auth, session *types.Session) *csrfResponseWriter {
return &csrfResponseWriter{
ResponseWriter: w,
auth: auth,
session: session,
}
}
func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
dataStr := string(data)
csrfToken, err := rr.auth.GetCsrfToken(rr.session)
if err == nil {
csrfInput := fmt.Sprintf(`<input type="hidden" name="csrf-token" value="%s" />`, csrfToken)
dataStr = strings.ReplaceAll(dataStr, "</form>", csrfInput+"</form>")
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", csrfToken)
}
return rr.ResponseWriter.Write([]byte(dataStr))
}
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := GetSession(r)
if r.Method == http.MethodPost ||
r.Method == http.MethodPut ||
r.Method == http.MethodDelete ||
r.Method == http.MethodPatch {
csrfToken := r.FormValue("csrf-token")
if csrfToken == "" {
csrfToken = r.Header.Get("csrf-token")
}
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(csrfToken, session.Id) {
log.Info("CSRF-Token \"%s\" not correct", csrfToken)
if r.Header.Get("HX-Request") == "true" {
utils.TriggerToastWithStatus(w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
} else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
}
return
}
}
responseWriter := newCsrfResponseWriter(w, auth, session)
next.ServeHTTP(responseWriter, r)
})
}
}

View File

@@ -2,11 +2,11 @@ package middleware
import (
"compress/gzip"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"spend-sparrow/log"
)
type gzipResponseWriter struct {
@@ -32,8 +32,9 @@ func Gzip(next http.Handler) http.Handler {
next.ServeHTTP(wrapper, r)
err := gz.Close()
if err != nil && !errors.Is(err, http.ErrBodyNotAllowed) {
slog.ErrorContext(r.Context(), "Gzip: could not close Writer", "err", err)
if err != nil && err != http.ErrBodyNotAllowed {
// if err != nil {
log.Error("Gzip: could not close Writer: %v", err)
}
})
}

View File

@@ -0,0 +1,47 @@
package middleware
import (
"net/http"
"strconv"
"time"
"spend-sparrow/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metrics = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mefit_request_total",
Help: "The total number of requests processed",
},
[]string{"path", "method", "status"},
)
)
type WrappedWriter struct {
http.ResponseWriter
StatusCode int
}
func (w *WrappedWriter) WriteHeader(code int) {
w.StatusCode = code
w.ResponseWriter.WriteHeader(code)
}
func Log(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &WrappedWriter{
ResponseWriter: w,
StatusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
log.Info(r.RemoteAddr + " " + strconv.Itoa(wrapped.StatusCode) + " " + r.Method + " " + r.URL.Path + " " + time.Since(start).String())
metrics.WithLabelValues(r.URL.Path, r.Method, http.StatusText(wrapped.StatusCode)).Inc()
})
}

View File

@@ -2,10 +2,12 @@ package middleware
import (
"net/http"
"spend-sparrow/internal/types"
"spend-sparrow/types"
)
func SecurityHeaders(serverSettings *types.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")
@@ -28,7 +30,7 @@ func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Han
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
if r.Method == http.MethodOptions {
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}

View File

@@ -2,12 +2,12 @@ package middleware
import "net/http"
// Wrapper wraps a list of handlers together.
// Chain list of handlers together
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastHandler := next
for _, handler := range handlers {
lastHandler = handler(lastHandler)
for i := 0; i < len(handlers); i++ {
lastHandler = handlers[i](lastHandler)
}
lastHandler.ServeHTTP(w, r)
})

View File

@@ -1,11 +1,12 @@
package handler
import (
"log/slog"
"spend-sparrow/log"
"spend-sparrow/template"
"spend-sparrow/template/auth"
"spend-sparrow/types"
"net/http"
"spend-sparrow/internal/template"
"spend-sparrow/internal/template/auth"
"spend-sparrow/internal/types"
"github.com/a-h/templ"
)
@@ -22,7 +23,7 @@ func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, c
w.WriteHeader(status)
err := comp.Render(r.Context(), w)
if err != nil {
slog.ErrorContext(r.Context(), "Failed to render layout", "err", err)
log.Error("Failed to render layout: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
@@ -43,6 +44,7 @@ func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWri
}
func (render *Render) getUserComp(user *types.User) templ.Component {
if user != nil {
return auth.UserComp(user.Email)
} else {

View File

@@ -2,10 +2,9 @@ package handler
import (
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template"
"spend-sparrow/internal/utils"
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
"spend-sparrow/template"
"github.com/a-h/templ"
)
@@ -15,14 +14,14 @@ type Index interface {
}
type IndexImpl struct {
r *Render
c service.Clock
service service.Auth
render *Render
}
func NewIndex(r *Render, c service.Clock) Index {
func NewIndex(service service.Auth, render *Render) Index {
return IndexImpl{
r: r,
c: c,
service: service,
render: render,
}
}
@@ -33,12 +32,8 @@ func (handler IndexImpl) Handle(router *http.ServeMux) {
func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
htmx := utils.IsHtmx(r)
var comp templ.Component
var status int
@@ -47,26 +42,19 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound
} else {
if user != nil {
utils.DoRedirect(w, r, "/dashboard")
return
comp = template.Dashboard()
} else {
comp = template.Index()
}
status = http.StatusOK
}
if htmx {
handler.r.RenderWithStatus(r, w, comp, status)
} else {
handler.r.RenderLayoutWithStatus(r, w, comp, user, status)
}
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
}
}
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
// Return nothing
}
}

View File

@@ -1,16 +1,12 @@
package handler
import (
"fmt"
"math"
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
t "spend-sparrow/internal/template/transaction"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"strconv"
"time"
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
t "spend-sparrow/template/transaction"
"spend-sparrow/types"
"spend-sparrow/utils"
"github.com/a-h/templ"
"github.com/google/uuid"
@@ -24,14 +20,16 @@ type TransactionImpl struct {
s service.Transaction
account service.Account
treasureChest service.TreasureChest
a service.Auth
r *Render
}
func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, r *Render) Transaction {
func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, a service.Auth, r *Render) Transaction {
return TransactionImpl{
s: s,
account: account,
treasureChest: treasureChest,
a: a,
r: r,
}
}
@@ -46,34 +44,25 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
filter := types.TransactionItemsFilter{
AccountId: r.URL.Query().Get("account-id"),
TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
Error: r.URL.Query().Get("error"),
Page: r.URL.Query().Get("page"),
}
transactions, err := h.s.GetAll(r.Context(), user, filter)
transactions, err := h.s.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
accounts, err := h.account.GetAll(r.Context(), user)
accounts, err := h.account.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
treasureChests, err := h.treasureChest.GetAll(user)
if err != nil {
handleError(w, r, err)
return
@@ -81,33 +70,26 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
items := t.TransactionItems(transactions, accountMap, treasureChestMap)
if utils.IsHtmx(r) {
h.r.Render(r, w, items)
} else {
comp := t.Transaction(items, filter, accounts, treasureChests)
comp := t.Transaction(transactions, accountMap, treasureChestMap)
h.r.RenderLayout(r, w, comp, user)
}
}
}
func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
accounts, err := h.account.GetAll(r.Context(), user)
accounts, err := h.account.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
treasureChests, err := h.treasureChest.GetAll(user)
if err != nil {
handleError(w, r, err)
return
@@ -120,7 +102,7 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
return
}
transaction, err := h.s.Get(r.Context(), user, id)
transaction, err := h.s.Get(user, id)
if err != nil {
handleError(w, r, err)
return
@@ -139,8 +121,6 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -148,86 +128,40 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
}
var (
id uuid.UUID
transaction *types.Transaction
err error
)
idStr := r.PathValue("id")
if idStr != "new" {
id, err = uuid.Parse(idStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest))
return
}
}
accountIdStr := r.FormValue("account-id")
var accountId *uuid.UUID
if accountIdStr != "" {
i, err := uuid.Parse(accountIdStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse account id: %w", service.ErrBadRequest))
return
}
accountId = &i
}
treasureChestIdStr := r.FormValue("treasure-chest-id")
var treasureChestId *uuid.UUID
if treasureChestIdStr != "" {
i, err := uuid.Parse(treasureChestIdStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", service.ErrBadRequest))
return
}
treasureChestId = &i
}
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse value: %w", service.ErrBadRequest))
return
}
value := int64(math.Round(valueF * service.DECIMALS_MULTIPLIER))
timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp"))
if err != nil {
handleError(w, r, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest))
return
}
input := types.Transaction{
Id: id,
AccountId: accountId,
TreasureChestId: treasureChestId,
Value: value,
Timestamp: timestamp,
input := types.TransactionInput{
Id: r.PathValue("id"),
AccountId: r.FormValue("account-id"),
TreasureChestId: r.FormValue("treasure-chest-id"),
Value: r.FormValue("value"),
Timestamp: r.FormValue("timestamp"),
Party: r.FormValue("party"),
Description: r.FormValue("description"),
}
var transaction *types.Transaction
if idStr == "new" {
transaction, err = h.s.Add(r.Context(), nil, user, input)
if input.Id == "new" {
transaction, err = h.s.Add(user, input)
if err != nil {
handleError(w, r, err)
return
}
} else {
transaction, err = h.s.Update(r.Context(), user, input)
transaction, err = h.s.Update(user, input)
if err != nil {
handleError(w, r, err)
return
}
}
accounts, err := h.account.GetAll(r.Context(), user)
accounts, err := h.account.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
treasureChests, err := h.treasureChest.GetAll(user)
if err != nil {
handleError(w, r, err)
return
@@ -241,28 +175,24 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
err := h.s.RecalculateBalances(r.Context(), user)
err := h.s.RecalculateBalances(user)
if err != nil {
handleError(w, r, err)
return
}
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
utils.TriggerToastWithStatus(w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
}
}
func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
@@ -271,7 +201,7 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
id := r.PathValue("id")
err := h.s.Delete(r.Context(), user, id)
err := h.s.Delete(user, id)
if err != nil {
handleError(w, r, err)
return
@@ -287,7 +217,7 @@ func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureC
treasureChestMap := make(map[uuid.UUID]string, 0)
root := ""
for _, treasureChest := range treasureChests {
if treasureChest.ParentId == nil {
if treasureChest.ParentId == uuid.Nil {
root = treasureChest.Name + " > "
treasureChestMap[treasureChest.Id] = treasureChest.Name
} else {

145
handler/treasure_chest.go Normal file
View File

@@ -0,0 +1,145 @@
package handler
import (
"net/http"
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
t "spend-sparrow/template/treasurechest"
"spend-sparrow/types"
"spend-sparrow/utils"
"github.com/a-h/templ"
)
type TreasureChest interface {
Handle(router *http.ServeMux)
}
type TreasureChestImpl struct {
s service.TreasureChest
a service.Auth
r *Render
}
func NewTreasureChest(s service.TreasureChest, a service.Auth, r *Render) TreasureChest {
return TreasureChestImpl{
s: s,
a: a,
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 TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := h.s.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
comp := t.TreasureChest(treasureChests)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := h.s.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
id := r.PathValue("id")
if id == "new" {
comp := t.EditTreasureChest(nil, treasureChests)
h.r.Render(r, w, comp)
return
}
treasureChest, err := h.s.Get(user, id)
if err != nil {
handleError(w, r, err)
return
}
var comp templ.Component
if r.URL.Query().Get("edit") == "true" {
comp = t.EditTreasureChest(treasureChest, treasureChests)
} else {
comp = t.TreasureChestItem(treasureChest)
}
h.r.Render(r, w, comp)
}
}
func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
var (
treasureChest *types.TreasureChest
err error
)
id := r.PathValue("id")
parentId := r.FormValue("parent-id")
name := r.FormValue("name")
if id == "new" {
treasureChest, err = h.s.Add(user, parentId, name)
if err != nil {
handleError(w, r, err)
return
}
} else {
treasureChest, err = h.s.Update(user, id, parentId, name)
if err != nil {
handleError(w, r, err)
return
}
}
comp := t.TreasureChestItem(treasureChest)
h.r.Render(r, w, comp)
}
}
func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
id := r.PathValue("id")
err := h.s.Delete(user, id)
if err != nil {
handleError(w, r, err)
return
}
}
}

View File

@@ -3,22 +3,25 @@
@source './static/**/*.js';
@source './template/**/*.templ';
@font-face {
font-family: "EB Garamond";
src: url("/static/font/EBGaramond-VariableFont_wght.woff2") format("woff2");
}
body {
font-family: "EB Garamond", serif;
@apply text-gray-700;
@apply font-shippori text-gray-700;
}
input:focus {
@apply outline-none ring-0;
}
button {
@apply cursor-pointer;
@font-face {
font-family: "Pirata One";
src: url("/static/font/pirata_one/PirataOne-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Shippori Mincho";
src: url("/static/font/shippori_mincho/ShipporiMincho-Medium.ttf") format("truetype");
}
@theme {
--font-pirata: "Pirata One", sans-serif;
--font-shippori: "Shippori Mincho", sans-serif;
}
/* Button */
@@ -53,10 +56,8 @@ button {
transition: all 150ms linear;
@apply px-3 py-2 text-lg;
}
.input:has(input:focus), .input:focus {
.input:has(input:focus) {
border-color: var(--color-gray-400);
box-shadow: 0 0 0 2px var(--color-gray-200);
}

View File

@@ -1,405 +0,0 @@
package db
import (
"context"
"database/sql"
"errors"
"log/slog"
"spend-sparrow/internal/types"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Auth interface {
InsertUser(ctx context.Context, user *types.User) error
UpdateUser(ctx context.Context, user *types.User) error
GetUserByEmail(ctx context.Context, email string) (*types.User, error)
GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error)
DeleteUser(ctx context.Context, userId uuid.UUID) error
InsertToken(ctx context.Context, token *types.Token) error
GetToken(ctx context.Context, token string) (*types.Token, error)
GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error)
DeleteToken(ctx context.Context, token string) error
InsertSession(ctx context.Context, session *types.Session) error
GetSession(ctx context.Context, sessionId string) (*types.Session, error)
GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error)
DeleteSession(ctx context.Context, sessionId string) error
DeleteOldSessions(ctx context.Context) error
DeleteOldTokens(ctx context.Context) error
}
type AuthSqlite struct {
db *sqlx.DB
}
func NewAuthSqlite(db *sqlx.DB) *AuthSqlite {
return &AuthSqlite{db: db}
}
func (db AuthSqlite) InsertUser(ctx context.Context, user *types.User) error {
_, err := db.db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
user.Id, user.Email, user.EmailVerified, user.EmailVerifiedAt, user.IsAdmin, user.Password, user.Salt, user.CreateAt)
if err != nil {
if strings.Contains(err.Error(), "email") {
return ErrAlreadyExists
}
slog.ErrorContext(ctx, "SQL error InsertUser", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) UpdateUser(ctx context.Context, user *types.User) error {
_, err := db.db.ExecContext(ctx, `
UPDATE user
SET email_verified = ?, email_verified_at = ?, password = ?
WHERE user_id = ?`,
user.EmailVerified, user.EmailVerifiedAt, user.Password, user.Id)
if err != nil {
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetUserByEmail(ctx context.Context, email string) (*types.User, error) {
var (
userId uuid.UUID
emailVerified bool
emailVerifiedAt *time.Time
isAdmin bool
password []byte
salt []byte
createdAt time.Time
)
err := db.db.QueryRowContext(ctx, `
SELECT user_id, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
} else {
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
return nil, types.ErrInternal
}
}
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error) {
var (
email string
emailVerified bool
emailVerifiedAt *time.Time
isAdmin bool
password []byte
salt []byte
createdAt time.Time
)
err := db.db.QueryRowContext(ctx, `
SELECT email, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
} else {
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
return nil, types.ErrInternal
}
}
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) DeleteUser(ctx context.Context, userId uuid.UUID) error {
tx, err := db.db.BeginTx(ctx, nil)
if err != nil {
slog.ErrorContext(ctx, "Could not start transaction", "err", err)
return types.ErrInternal
}
_, err = tx.ExecContext(ctx, "DELETE FROM account WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete accounts", "err", err)
return types.ErrInternal
}
_, err = tx.ExecContext(ctx, "DELETE FROM token WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user tokens", "err", err)
return types.ErrInternal
}
_, err = tx.ExecContext(ctx, "DELETE FROM session WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete sessions", "err", err)
return types.ErrInternal
}
_, err = tx.ExecContext(ctx, "DELETE FROM user WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal
}
_, err = tx.ExecContext(ctx, "DELETE FROM treasure_chest WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal
}
_, err = tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal
}
err = tx.Commit()
if err != nil {
slog.ErrorContext(ctx, "Could not commit transaction", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertToken(ctx context.Context, token *types.Token) error {
_, err := db.db.ExecContext(ctx, `
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
if err != nil {
slog.ErrorContext(ctx, "Could not insert token", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetToken(ctx context.Context, token string) (*types.Token, error) {
var (
userId uuid.UUID
sessionId string
tokenType types.TokenType
createdAtStr string
expiresAtStr string
createdAt time.Time
expiresAt time.Time
)
err := db.db.QueryRowContext(ctx, `
SELECT user_id, session_id, type, created_at, expires_at
FROM token
WHERE token = ?`, token).Scan(&userId, &sessionId, &tokenType, &createdAtStr, &expiresAtStr)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
slog.InfoContext(ctx, "Token not found", "token", token)
return nil, ErrNotFound
} else {
slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal
}
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
return nil, types.ErrInternal
}
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.QueryContext(ctx, `
SELECT token, created_at, expires_at
FROM token
WHERE user_id = ?
AND type = ?`, userId, tokenType)
if err != nil {
slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(ctx, query, userId, "", tokenType)
}
func (db AuthSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.QueryContext(ctx, `
SELECT token, created_at, expires_at
FROM token
WHERE session_id = ?
AND type = ?`, sessionId, tokenType)
if err != nil {
slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(ctx, query, uuid.Nil, sessionId, tokenType)
}
func getTokensFromQuery(ctx context.Context, query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
var tokens []*types.Token
hasRows := false
for query.Next() {
hasRows = true
var (
token string
createdAtStr string
expiresAtStr string
createdAt time.Time
expiresAt time.Time
)
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
if err != nil {
slog.ErrorContext(ctx, "Could not scan token", "err", err)
return nil, types.ErrInternal
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
return nil, types.ErrInternal
}
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
}
if !hasRows {
return nil, ErrNotFound
}
return tokens, nil
}
func (db AuthSqlite) DeleteToken(ctx context.Context, token string) error {
_, err := db.db.ExecContext(ctx, "DELETE FROM token WHERE token = ?", token)
if err != nil {
slog.ErrorContext(ctx, "Could not delete token", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertSession(ctx context.Context, session *types.Session) error {
_, err := db.db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
if err != nil {
slog.ErrorContext(ctx, "Could not insert new session", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetSession(ctx context.Context, sessionId string) (*types.Session, error) {
var (
userId uuid.UUID
createdAt time.Time
expiresAt time.Time
)
err := db.db.QueryRowContext(ctx, `
SELECT user_id, created_at, expires_at
FROM session
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
if err != nil {
slog.WarnContext(ctx, "Session not found", "session-id", sessionId, "err", err)
return nil, ErrNotFound
}
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error) {
var sessions []*types.Session
err := db.db.SelectContext(ctx, &sessions, `
SELECT *
FROM session
WHERE user_id = ?`, userId)
if err != nil {
slog.ErrorContext(ctx, "Could not get sessions", "err", err)
return nil, types.ErrInternal
}
return sessions, nil
}
func (db AuthSqlite) DeleteSession(ctx context.Context, sessionId string) error {
if sessionId != "" {
_, err := db.db.ExecContext(ctx, "DELETE FROM session WHERE session_id = ?", sessionId)
if err != nil {
slog.ErrorContext(ctx, "Could not delete session", "err", err)
return types.ErrInternal
}
}
return nil
}
func (db AuthSqlite) DeleteOldSessions(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, `
DELETE FROM session
WHERE expires_at < datetime('now')`)
if err != nil {
slog.ErrorContext(ctx, "Could not delete old sessions", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) DeleteOldTokens(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, `
DELETE FROM token
WHERE expires_at < datetime('now')`)
if err != nil {
slog.ErrorContext(ctx, "Could not delete old tokens", "err", err)
return types.ErrInternal
}
return nil
}

View File

@@ -1,48 +0,0 @@
package db
import (
"context"
"errors"
"log/slog"
"spend-sparrow/internal/types"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jmoiron/sqlx"
)
type migrationLogger struct{}
func (l migrationLogger) Printf(format string, v ...any) {
slog.Info(format, v...)
}
func (l migrationLogger) Verbose() bool {
return false
}
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 types.ErrInternal
}
m, err := migrate.NewWithDatabaseInstance(
"file://"+pathPrefix+"migration/",
"",
driver)
if err != nil {
slog.ErrorContext(ctx, "Could not create migrations instance", "err", err)
return types.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 types.ErrInternal
}
return nil
}

View File

@@ -1,178 +0,0 @@
package internal
import (
"errors"
"fmt"
"log/slog"
"spend-sparrow/internal/db"
"spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/log"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
"context"
"net/http"
"os/signal"
"sync"
"syscall"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env func(string) string) error {
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
otelEnabled := types.IsOtelEnabled(env)
if otelEnabled {
// use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled
otelShutdown, err := setupOTelSDK(context.Background())
if err != nil {
return fmt.Errorf("could not setup OpenTelemetry SDK: %w", err)
}
defer func() {
// User context.Background(), as the main context is already cancelled
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err = otelShutdown(ctx)
if err != nil {
slog.ErrorContext(ctx, "error shutting down OpenTelemetry SDK", "err", err)
}
cancel()
}()
slog.SetDefault(log.NewLogPropagator())
}
slog.InfoContext(ctx, "Starting server...")
// init server settings
serverSettings, err := types.NewSettingsFromEnv(ctx, env)
if err != nil {
return err
}
// init db
err = db.RunMigrations(ctx, database, migrationsPrefix)
if err != nil {
return fmt.Errorf("could not run migrations: %w", err)
}
// init server
httpServer := &http.Server{
Addr: ":" + serverSettings.Port,
Handler: createHandlerWithServices(ctx, database, serverSettings),
ReadHeaderTimeout: 2 * time.Second,
}
go startServer(ctx, httpServer)
// graceful shutdown
var wg sync.WaitGroup
wg.Add(1)
go shutdownServer(ctx, httpServer, &wg)
wg.Wait()
return nil
}
func startServer(ctx context.Context, s *http.Server) {
slog.InfoContext(ctx, "Starting server", "addr", s.Addr)
if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.ErrorContext(ctx, "error listening and serving", "err", err)
}
}
func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
defer wg.Done()
if s == nil {
return
}
<-ctx.Done()
shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer cancel()
if err := s.Shutdown(shutdownCtx); err != nil {
slog.ErrorContext(ctx, "error shutting down http server", "err", err)
} else {
slog.InfoContext(ctx, "Gracefully stopped http server", "addr", s.Addr)
}
}
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler {
var router = http.NewServeMux()
authDb := db.NewAuthSqlite(d)
randomService := service.NewRandom()
clockService := service.NewClock()
mailService := service.NewMail(serverSettings)
authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings)
accountService := service.NewAccount(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)
render := handler.NewRender()
indexHandler := handler.NewIndex(render, clockService)
dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService)
authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render)
go dailyTaskTimer(ctx, transactionRecurringService, authService)
indexHandler.Handle(router)
dashboardHandler.Handle(router)
accountHandler.Handle(router)
treasureChestHandler.Handle(router)
authHandler.Handle(router)
transactionHandler.Handle(router)
transactionRecurringHandler.Handle(router)
// Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
wrapper := middleware.Wrapper(
router,
middleware.SecurityHeaders(serverSettings),
middleware.CacheControl,
middleware.CrossSiteRequestForgery(authService),
middleware.Authenticate(authService),
middleware.Gzip,
middleware.Log,
)
wrapper = otelhttp.NewHandler(wrapper, "http.request")
return wrapper
}
func dailyTaskTimer(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
runDailyTasks(ctx, transactionRecurring, auth)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runDailyTasks(ctx, transactionRecurring, auth)
}
}
}
func runDailyTasks(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
slog.InfoContext(ctx, "Running daily tasks")
_ = transactionRecurring.GenerateTransactions(ctx)
_ = auth.CleanupSessionsAndTokens(ctx)
}

View File

@@ -1,254 +0,0 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/utils"
"strings"
"time"
"github.com/google/uuid"
)
type Dashboard interface {
Handle(router *http.ServeMux)
}
type DashboardImpl struct {
r *Render
d *service.Dashboard
treasureChest service.TreasureChest
}
func NewDashboard(r *Render, d *service.Dashboard, treasureChest service.TreasureChest) Dashboard {
return DashboardImpl{
r: r,
d: d,
treasureChest: treasureChest,
}
}
func (handler DashboardImpl) 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())
}
func (handler DashboardImpl) handleDashboard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := handler.treasureChest.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
comp := dashboard.Dashboard(treasureChests)
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
}
}
func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
series, err := handler.d.MainChart(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
accountBuilder := strings.Builder{}
savingsBuilder := strings.Builder{}
for _, entry := range series {
accountBuilder.WriteString(fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100))
savingsBuilder.WriteString(fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100))
}
account := accountBuilder.String()
savings := savingsBuilder.String()
account = account[:len(account)-1]
savings = savings[:len(savings)-1]
_, err = fmt.Fprintf(w, `
{
"aria": {
"enabled": true
},
"tooltip": {
"trigger": "axis",
"formatter": "<updated by client>"
},
"xAxis": {
"type": "time"
},
"yAxis": {
"axisLabel": {
"formatter": "{value} €"
}
},
"series": [
{
"data": [%s],
"type": "line",
"name": "Account Value"
},
{
"data": [%s],
"type": "line",
"name": "Savings"
}
]
}
`, account, savings)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}
func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
treeList, err := handler.d.TreasureChests(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
dataBuilder := strings.Builder{}
for _, item := range treeList {
childrenBuilder := strings.Builder{}
for _, child := range item.Children {
if child.Value < 0 {
childrenBuilder.WriteString(fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value))
} else {
childrenBuilder.WriteString(fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value))
}
}
children := childrenBuilder.String()
children = children[:len(children)-1]
dataBuilder.WriteString(fmt.Sprintf(`{"name":"%s","children":[%s]},`, item.Name, children))
}
data := dataBuilder.String()
data = data[:len(data)-1]
_, err = fmt.Fprintf(w, `
{
"aria": {
"enabled": true
},
"series": [
{
"data": [%s],
"type": "treemap",
"name": "Savings"
}
]
}
`, data)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}
func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
var treasureChestId *uuid.UUID
treasureChestStr := r.URL.Query().Get("id")
if treasureChestStr != "" {
id, err := uuid.Parse(treasureChestStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse treasure chest: %w", service.ErrBadRequest))
return
}
treasureChestId = &id
}
series, err := handler.d.TreasureChest(r.Context(), user, treasureChestId)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
valueBuilder := strings.Builder{}
for _, entry := range series {
valueBuilder.WriteString(fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100))
}
value := valueBuilder.String()
if len(value) > 0 {
value = value[:len(value)-1]
}
_, err = fmt.Fprintf(w, `
{
"aria": {
"enabled": true
},
"tooltip": {
"trigger": "axis",
"formatter": "<updated by client>"
},
"xAxis": {
"type": "time"
},
"yAxis": {
"axisLabel": {
"formatter": "{value} €"
}
},
"series": [
{
"data": [%s],
"type": "line",
"name": "Treasure Chest Value"
}
]
}
`, value)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}

View File

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

View File

@@ -1,76 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"spend-sparrow/internal/service"
"spend-sparrow/internal/utils"
"strings"
)
type csrfResponseWriter struct {
http.ResponseWriter
csrfToken string
}
func newCsrfResponseWriter(w http.ResponseWriter, csrfToken string) *csrfResponseWriter {
return &csrfResponseWriter{
ResponseWriter: w,
csrfToken: csrfToken,
}
}
func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
dataStr := string(data)
if rr.csrfToken != "" {
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", rr.csrfToken)
}
return rr.ResponseWriter.Write([]byte(dataStr))
}
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if strings.Contains(r.URL.Path, "/static/") {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
session := GetSession(r)
if r.Method == http.MethodPost ||
r.Method == http.MethodPut ||
r.Method == http.MethodDelete ||
r.Method == http.MethodPatch {
csrfToken := r.Header.Get("Csrf-Token")
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)
} else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
}
return
}
}
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)
} else {
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
}
return
}
responseWriter := newCsrfResponseWriter(w, token)
next.ServeHTTP(responseWriter, r)
})
}
}

View File

@@ -1,37 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
type WrappedWriter struct {
http.ResponseWriter
StatusCode int
}
func (w *WrappedWriter) WriteHeader(code int) {
w.StatusCode = code
w.ResponseWriter.WriteHeader(code)
}
func Log(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
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())
})
}

View File

@@ -1,136 +0,0 @@
package handler
import (
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
t "spend-sparrow/internal/template/transaction_recurring"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
)
type TransactionRecurring interface {
Handle(router *http.ServeMux)
}
type TransactionRecurringImpl struct {
s service.TransactionRecurring
r *Render
}
func NewTransactionRecurring(s service.TransactionRecurring, r *Render) TransactionRecurring {
return TransactionRecurringImpl{
s: s,
r: r,
}
}
func (h TransactionRecurringImpl) 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 {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
id := r.URL.Query().Get("id")
accountId := r.URL.Query().Get("account-id")
treasureChestId := r.URL.Query().Get("treasure-chest-id")
h.renderItems(w, r, user, id, accountId, treasureChestId)
}
}
func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
input := types.TransactionRecurringInput{
Id: r.PathValue("id"),
IntervalMonths: r.FormValue("interval-months"),
NextExecution: r.FormValue("next-execution"),
Party: r.FormValue("party"),
Description: r.FormValue("description"),
AccountId: r.FormValue("account-id"),
TreasureChestId: r.FormValue("treasure-chest-id"),
Value: r.FormValue("value"),
}
if input.Id == "new" {
_, err := h.s.Add(r.Context(), user, input)
if err != nil {
handleError(w, r, err)
return
}
} else {
_, err := h.s.Update(r.Context(), user, input)
if err != nil {
handleError(w, r, err)
return
}
}
h.renderItems(w, r, user, "", input.AccountId, input.TreasureChestId)
}
}
func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
id := r.PathValue("id")
accountId := r.URL.Query().Get("account-id")
treasureChestId := r.URL.Query().Get("treasure-chest-id")
err := h.s.Delete(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
}
h.renderItems(w, r, user, "", accountId, treasureChestId)
}
}
func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *types.User, id, accountId, treasureChestId string) {
var transactionsRecurring []*types.TransactionRecurring
var err error
if accountId == "" && treasureChestId == "" {
utils.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)
if err != nil {
handleError(w, r, err)
return
}
} else {
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
if err != nil {
handleError(w, r, err)
return
}
}
comp := t.TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
h.r.Render(r, w, comp)
}

View File

@@ -1,196 +0,0 @@
package handler
import (
"net/http"
"spend-sparrow/internal/handler/middleware"
"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"
"github.com/a-h/templ"
"github.com/google/uuid"
)
type TreasureChest interface {
Handle(router *http.ServeMux)
}
type TreasureChestImpl struct {
s service.TreasureChest
transactionRecurring service.TransactionRecurring
r *Render
}
func NewTreasureChest(s service.TreasureChest, transactionRecurring service.TransactionRecurring, r *Render) TreasureChest {
return TreasureChestImpl{
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 TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp := t.TreasureChest(treasureChests, monthlySums)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
id := r.PathValue("id")
if id == "new" {
comp := t.EditTreasureChest(nil, treasureChests, nil)
h.r.Render(r, w, comp)
return
}
treasureChest, err := h.s.Get(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
}
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil {
handleError(w, r, err)
return
}
transactionsRec := tr.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
var comp templ.Component
if r.URL.Query().Get("edit") == "true" {
comp = t.EditTreasureChest(treasureChest, treasureChests, transactionsRec)
} else {
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp = t.TreasureChestItem(treasureChest, monthlySums)
}
h.r.Render(r, w, comp)
}
}
func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
var (
treasureChest *types.TreasureChest
err error
)
id := r.PathValue("id")
parentId := r.FormValue("parent-id")
name := r.FormValue("name")
if id == "new" {
treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
if err != nil {
handleError(w, r, err)
return
}
} else {
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
if err != nil {
handleError(w, r, err)
return
}
}
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil {
handleError(w, r, err)
return
}
treasureChests := make([]*types.TreasureChest, 1)
treasureChests[0] = treasureChest
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp := t.TreasureChestItem(treasureChest, monthlySums)
h.r.Render(r, w, comp)
}
}
func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
id := r.PathValue("id")
err := h.s.Delete(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
}
}
}
func (h TreasureChestImpl) calculateMonthlySums(
treasureChests []*types.TreasureChest,
transactionsRecurring []*types.TransactionRecurring,
) map[uuid.UUID]int64 {
monthlySums := make(map[uuid.UUID]int64)
for _, tc := range treasureChests {
monthlySums[tc.Id] = 0
}
for _, t := range transactionsRecurring {
if t.TreasureChestId != nil && t.Value > 0 && t.IntervalMonths > 0 {
monthlySums[*t.TreasureChestId] += t.Value / t.IntervalMonths
}
}
return monthlySums
}

View File

@@ -1,50 +0,0 @@
package log
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/contrib/bridges/otelslog"
)
func NewLogPropagator() *slog.Logger {
return slog.New(&logHandler{
console: slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}),
otel: otelslog.NewHandler("spend-sparrow"),
})
}
type logHandler struct {
console slog.Handler
otel slog.Handler
}
// Enabled implements slog.Handler.
func (l *logHandler) Enabled(ctx context.Context, level slog.Level) bool {
return l.console.Enabled(ctx, level)
}
// Handle implements slog.Handler.
func (l *logHandler) Handle(ctx context.Context, rec slog.Record) error {
if err := l.console.Handle(ctx, rec); err != nil {
return err
}
return l.otel.Handle(ctx, rec)
}
// WithAttrs implements slog.Handler.
func (l *logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &logHandler{
console: l.console.WithAttrs(attrs),
otel: l.otel.WithAttrs(attrs),
}
}
// WithGroup implements slog.Handler.
func (l *logHandler) WithGroup(name string) slog.Handler {
return &logHandler{
console: l.console.WithGroup(name),
otel: l.otel.WithGroup(name),
}
}

View File

@@ -1,143 +0,0 @@
package internal
import (
"context"
"errors"
"log/slog"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"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.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctxInternal context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctxInternal))
}
shutdownFuncs = nil
return err
}
var err error
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(ctxInternal context.Context, inErr error) {
err = errors.Join(inErr, shutdown(ctxInternal))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
resources, err := resource.New(
ctx,
resource.WithAttributes(semconv.ServiceName("spend-sparrow")),
)
if err != nil {
slog.ErrorContext(ctx, "failed to create resource", "error", err)
}
// Set up trace provider.
tracerProvider, err := newTracerProvider(ctx, resources)
if err != nil {
handleErr(ctx, err)
return nil, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider(ctx, resources)
if err != nil {
handleErr(ctx, err)
return nil, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider(ctx, resources)
if err != nil {
handleErr(ctx, err)
return nil, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, nil
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace.TracerProvider, error) {
exp, err := otlptracegrpc.New(
ctx,
otlptracegrpc.WithEndpoint(otelEndpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
return trace.NewTracerProvider(
trace.WithBatcher(exp),
trace.WithResource(resource),
), nil
}
func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric.MeterProvider, error) {
exp, err := otlpmetricgrpc.New(
ctx,
otlpmetricgrpc.WithInsecure(),
otlpmetricgrpc.WithEndpoint(otelEndpoint))
if err != nil {
return nil, err
}
return metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(exp, metric.WithInterval(15*time.Second))),
metric.WithResource(resource),
), nil
}
func newLoggerProvider(ctx context.Context, resource *resource.Resource) (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(
ctx,
otlploggrpc.WithInsecure(),
otlploggrpc.WithEndpoint(otelEndpoint))
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
log.WithResource(resource),
)
return loggerProvider, nil
}

View File

@@ -1,219 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Account interface {
Add(ctx context.Context, user *types.User, name string) (*types.Account, error)
UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error)
Get(ctx context.Context, user *types.User, id string) (*types.Account, error)
GetAll(ctx context.Context, user *types.User) ([]*types.Account, error)
Delete(ctx context.Context, user *types.User, id string) error
}
type AccountImpl struct {
db *sqlx.DB
clock Clock
random Random
}
func NewAccount(db *sqlx.DB, random Random, clock Clock) Account {
return AccountImpl{
db: db,
clock: clock,
random: random,
}
}
func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
err = validateString(name, "name")
if err != nil {
return nil, err
}
account := &types.Account{
Id: newId,
UserId: user.Id,
Name: name,
CurrentBalance: 0,
LastTransaction: nil,
OinkBalance: 0,
CreatedAt: s.clock.Now(),
CreatedBy: user.Id,
UpdatedAt: nil,
UpdatedBy: nil,
}
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)
if err != nil {
return nil, err
}
return account, nil
}
func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
err := validateString(name, "name")
if err != nil {
return nil, err
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "account update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
var account types.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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
}
return nil, types.ErrInternal
}
timestamp := s.clock.Now()
account.Name = name
account.UpdatedAt = &timestamp
account.UpdatedBy = &user.Id
r, err := tx.NamedExecContext(ctx, `
UPDATE account
SET
name = :name,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, account)
err = db.TransformAndLogDbError(ctx, "account Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil {
return nil, err
}
return &account, nil
}
func (s AccountImpl) Get(ctx context.Context, user *types.User, id string) (*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "account get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var account types.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)
if err != nil {
slog.ErrorContext(ctx, "account get", "err", err)
return nil, err
}
return &account, nil
}
func (s AccountImpl) GetAll(ctx context.Context, user *types.User) ([]*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
accounts := make([]*types.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)
if err != nil {
return nil, err
}
return accounts, nil
}
func (s AccountImpl) Delete(ctx context.Context, user *types.User, id string) error {
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "account delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
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)
if err != nil {
return err
}
if transactionsCount > 0 {
return fmt.Errorf("account has transactions, cannot delete: %w", ErrBadRequest)
}
res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError(ctx, "account Delete", res, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil {
return err
}
return nil
}

View File

@@ -1,175 +0,0 @@
package service
import (
"context"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Dashboard struct {
db *sqlx.DB
}
func NewDashboard(db *sqlx.DB) *Dashboard {
return &Dashboard{
db: db,
}
}
func (s Dashboard) MainChart(
ctx context.Context,
user *types.User,
) ([]types.DashboardMainChartEntry, error) {
if user == nil {
return nil, ErrUnauthorized
}
transactions := make([]types.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)
if err != nil {
return nil, err
}
timeEntries := make([]types.DashboardMainChartEntry, 0)
var lastEntry *types.DashboardMainChartEntry
for _, t := range transactions {
if t.Error != nil {
continue
}
newDay := t.Timestamp.Truncate(24 * time.Hour)
if lastEntry == nil {
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: 0,
Savings: 0,
}
} else if lastEntry.Day != newDay {
timeEntries = append(timeEntries, *lastEntry)
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: lastEntry.Value,
Savings: lastEntry.Savings,
}
}
if t.AccountId != nil {
lastEntry.Value += t.Value
}
if t.TreasureChestId != nil {
lastEntry.Savings += t.Value
}
}
if lastEntry != nil {
timeEntries = append(timeEntries, *lastEntry)
}
return timeEntries, nil
}
func (s Dashboard) TreasureChests(
ctx context.Context,
user *types.User,
) ([]*types.DashboardTreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
treasureChests := make([]*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)
if err != nil {
return nil, err
}
treasureChests = sortTreasureChests(treasureChests)
result := make([]*types.DashboardTreasureChest, 0)
for _, t := range treasureChests {
if t.ParentId == nil {
result = append(result, &types.DashboardTreasureChest{
Name: t.Name,
Value: t.CurrentBalance,
Children: make([]types.DashboardTreasureChest, 0),
})
} else {
result[len(result)-1].Children = append(result[len(result)-1].Children, types.DashboardTreasureChest{
Name: t.Name,
Value: t.CurrentBalance,
Children: make([]types.DashboardTreasureChest, 0),
})
}
}
return result, nil
}
func (s Dashboard) TreasureChest(
ctx context.Context,
user *types.User,
treausureChestId *uuid.UUID,
) ([]types.DashboardMainChartEntry, error) {
if user == nil {
return nil, ErrUnauthorized
}
transactions := make([]types.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)
if err != nil {
return nil, err
}
timeEntries := make([]types.DashboardMainChartEntry, 0)
var lastEntry *types.DashboardMainChartEntry
for _, t := range transactions {
if t.Error != nil {
continue
}
newDay := t.Timestamp.Truncate(24 * time.Hour)
if lastEntry == nil {
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: 0,
}
} else if lastEntry.Day != newDay {
timeEntries = append(timeEntries, *lastEntry)
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: lastEntry.Value,
}
}
if t.TreasureChestId != nil {
lastEntry.Value += t.Value
}
}
if lastEntry != nil {
timeEntries = append(timeEntries, *lastEntry)
}
return timeEntries, nil
}

View File

@@ -1,56 +0,0 @@
package service
import (
"context"
"fmt"
"log/slog"
"net/smtp"
"spend-sparrow/internal/types"
)
type Mail interface {
// Sending an email is a fire and forget operation. Thus no error handling
SendMail(ctx context.Context, to string, subject string, message string)
}
type MailImpl struct {
server *types.Settings
}
func NewMail(server *types.Settings) MailImpl {
return MailImpl{server: server}
}
func (m MailImpl) SendMail(ctx context.Context, to string, subject string, message string) {
go m.internalSendMail(ctx, to, subject, message)
}
func (m MailImpl) internalSendMail(ctx context.Context, to string, subject string, message string) {
if m.server.Smtp == nil {
return
}
s := m.server.Smtp
auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
msg := fmt.Sprintf(
`From: %v <%v>
To: %v
Subject: %v
MIME-version: 1.0;
Content-Type: text/html; charset="UTF-8";
%v`,
s.FromName,
s.FromMail,
to,
subject,
message)
slog.InfoContext(ctx, "sending mail", "to", to)
err := smtp.SendMail(s.Host+":"+s.Port, auth, s.FromMail, []string{to}, []byte(msg))
if err != nil {
slog.ErrorContext(ctx, "Error sending mail", "err", err)
}
}

View File

@@ -1,55 +0,0 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"log/slog"
"spend-sparrow/internal/types"
"github.com/google/uuid"
)
type Random interface {
Bytes(ctx context.Context, size int) ([]byte, error)
String(ctx context.Context, size int) (string, error)
UUID(ctx context.Context) (uuid.UUID, error)
}
type RandomImpl struct {
}
func NewRandom() *RandomImpl {
return &RandomImpl{}
}
func (r *RandomImpl) Bytes(ctx context.Context, tsize int) ([]byte, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
slog.ErrorContext(ctx, "Error generating random bytes", "err", err)
return []byte{}, types.ErrInternal
}
return b, nil
}
func (r *RandomImpl) String(ctx context.Context, size int) (string, error) {
bytes, err := r.Bytes(ctx, size)
if err != nil {
slog.ErrorContext(ctx, "Error generating random string", "err", err)
return "", types.ErrInternal
}
return base64.StdEncoding.EncodeToString(bytes), nil
}
func (r *RandomImpl) UUID(ctx context.Context) (uuid.UUID, error) {
id, err := uuid.NewRandom()
if err != nil {
slog.ErrorContext(ctx, "Error generating random UUID", "err", err)
return uuid.Nil, types.ErrInternal
}
return id, nil
}

View File

@@ -1,553 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
const page_size = 25
type Transaction interface {
Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transaction types.Transaction) (*types.Transaction, error)
Update(ctx context.Context, user *types.User, transaction types.Transaction) (*types.Transaction, error)
Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error)
GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
Delete(ctx context.Context, user *types.User, id string) error
RecalculateBalances(ctx context.Context, user *types.User) error
}
type TransactionImpl struct {
db *sqlx.DB
clock Clock
random Random
}
func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction {
return TransactionImpl{
db: db,
clock: clock,
random: random,
}
}
func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transactionInput types.Transaction) (*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
var err error
ownsTransaction := false
if tx == nil {
ownsTransaction = true
tx, err = s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
}
transaction, err := s.validateAndEnrichTransaction(ctx, tx, nil, user.Id, transactionInput)
if err != nil {
return nil, err
}
r, err := tx.NamedExecContext(ctx, `
INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp,
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)
if err != nil {
return nil, err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err = tx.ExecContext(ctx, `
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)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err = tx.ExecContext(ctx, `
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)
if err != nil {
return nil, err
}
}
if ownsTransaction {
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
if err != nil {
return nil, err
}
}
return transaction, nil
}
func (s TransactionImpl) Update(ctx context.Context, user *types.User, input types.Transaction) (*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transaction := &types.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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest)
}
return nil, types.ErrInternal
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.ExecContext(ctx, `
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)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.ExecContext(ctx, `
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)
if err != nil {
return nil, err
}
}
transaction, err = s.validateAndEnrichTransaction(ctx, tx, transaction, user.Id, input)
if err != nil {
return nil, err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.ExecContext(ctx, `
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)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.ExecContext(ctx, `
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)
if err != nil {
return nil, err
}
}
r, err := tx.NamedExecContext(ctx, `
UPDATE "transaction"
SET
account_id = :account_id,
treasure_chest_id = :treasure_chest_id,
value = :value,
timestamp = :timestamp,
party = :party,
description = :description,
error = :error,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, transaction)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil {
return nil, err
}
return transaction, nil
}
func (s TransactionImpl) Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "transaction get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var transaction types.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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest)
}
return nil, types.ErrInternal
}
return &transaction, nil
}
func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
var (
page int64
offset int64
err error
)
if filter.Page != "" {
page, err = strconv.ParseInt(filter.Page, 10, 64)
if err != nil {
offset = 0
} else {
offset = page - 1
offset *= page_size
}
}
transactions := make([]*types.Transaction, 0)
err = s.db.SelectContext(ctx, &transactions, `
SELECT *
FROM "transaction"
WHERE user_id = ?
AND ($1 = '' OR account_id = $1)
AND ($2 = '' OR treasure_chest_id = $2)
AND ($3 = ''
OR ($3 = "true" AND error IS NOT NULL)
OR ($3 = "false" AND error IS NULL)
)
ORDER BY timestamp DESC, created_at DESC
LIMIT $4 OFFSET $5
`,
user.Id,
filter.AccountId,
filter.TreasureChestId,
filter.Error,
page_size,
offset)
err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
if err != nil {
return nil, err
}
return transactions, nil
}
func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string) error {
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "transaction delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
var transaction types.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)
if err != nil {
return err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.ExecContext(ctx, `
UPDATE account
SET current_balance = current_balance - ?
WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = current_balance - ?
WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil && !errors.Is(err, db.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)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.User) error {
if user == nil {
return ErrUnauthorized
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
r, err := tx.ExecContext(ctx, `
UPDATE account
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
r, err = tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
rows, err := tx.QueryxContext(ctx, `
SELECT *
FROM "transaction"
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
defer func() {
err := rows.Close()
if err != nil {
slog.ErrorContext(ctx, "transaction RecalculateBalances", "err", err)
}
}()
var transaction types.Transaction
for rows.Next() {
err = rows.StructScan(&transaction)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
s.updateErrors(&transaction)
r, err = tx.ExecContext(ctx, `
UPDATE "transaction"
SET error = ?
WHERE user_id = ?
AND id = ?`, transaction.Error, user.Id, transaction.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil {
return err
}
if transaction.Error != nil {
continue
}
if transaction.AccountId != nil {
r, err = tx.ExecContext(ctx, `
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)
if err != nil {
return err
}
}
if transaction.TreasureChestId != nil {
r, err = tx.ExecContext(ctx, `
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)
if err != nil {
return err
}
}
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) {
var (
id uuid.UUID
createdAt time.Time
createdBy uuid.UUID
updatedAt *time.Time
updatedBy uuid.UUID
err error
rowCount int
)
if oldTransaction == nil {
id, err = s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
createdAt = s.clock.Now()
createdBy = userId
} else {
id = oldTransaction.Id
createdAt = oldTransaction.CreatedAt
createdBy = oldTransaction.CreatedBy
time := s.clock.Now()
updatedAt = &time
updatedBy = userId
}
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)
if err != nil {
return nil, err
}
if rowCount == 0 {
slog.ErrorContext(ctx, "transaction validate", "err", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
}
}
if input.TreasureChestId != nil {
var treasureChest 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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
}
return nil, err
}
if treasureChest.ParentId == nil {
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest)
}
}
if input.Party != "" {
err = validateString(input.Party, "party")
if err != nil {
return nil, err
}
}
if input.Description != "" {
err = validateString(input.Description, "description")
if err != nil {
return nil, err
}
}
transaction := types.Transaction{
Id: id,
UserId: userId,
AccountId: input.AccountId,
TreasureChestId: input.TreasureChestId,
Value: input.Value,
Timestamp: input.Timestamp,
Party: input.Party,
Description: input.Description,
Error: nil,
CreatedAt: createdAt,
CreatedBy: createdBy,
UpdatedAt: updatedAt,
UpdatedBy: &updatedBy,
}
s.updateErrors(&transaction)
return &transaction, nil
}
func (s TransactionImpl) updateErrors(t *types.Transaction) {
errorStr := ""
switch {
case (t.AccountId != nil && t.TreasureChestId != nil && t.Value > 0) ||
(t.AccountId == nil && t.TreasureChestId == nil):
errorStr = "either an account or a treasure chest needs to be specified"
case t.Value == 0:
errorStr = "\"value\" needs to be specified"
}
if errorStr == "" {
t.Error = nil
} else {
t.Error = &errorStr
}
}

View File

@@ -1,527 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type TransactionRecurring interface {
Add(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
Update(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error)
GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error)
GetAllByTreasureChest(ctx context.Context, user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
Delete(ctx context.Context, user *types.User, id string) error
GenerateTransactions(ctx context.Context) error
}
type TransactionRecurringImpl struct {
db *sqlx.DB
clock Clock
random Random
transaction Transaction
}
func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transaction Transaction) TransactionRecurring {
return TransactionRecurringImpl{
db: db,
clock: clock,
random: random,
transaction: transaction,
}
}
func (s TransactionRecurringImpl) Add(ctx context.Context,
user *types.User,
transactionRecurringInput types.TransactionRecurringInput,
) (*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transactionRecurring, err := s.validateAndEnrichTransactionRecurring(ctx, tx, nil, user.Id, transactionRecurringInput)
if err != nil {
return nil, err
}
r, err := tx.NamedExecContext(ctx, `
INSERT INTO "transaction_recurring" (id, user_id, interval_months,
next_execution, party, description, account_id, treasure_chest_id, value, created_at, created_by)
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)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
if err != nil {
return nil, err
}
return transactionRecurring, nil
}
func (s TransactionRecurringImpl) Update(ctx context.Context,
user *types.User,
input types.TransactionRecurringInput,
) (*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(input.Id)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transactionRecurring := &types.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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, ErrBadRequest)
}
return nil, types.ErrInternal
}
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input)
if err != nil {
return nil, err
}
r, err := tx.NamedExecContext(ctx, `
UPDATE transaction_recurring
SET
interval_months = :interval_months,
next_execution = :next_execution,
party = :party,
description = :description,
account_id = :account_id,
treasure_chest_id = :treasure_chest_id,
value = :value,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, transactionRecurring)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil {
return nil, err
}
return transactionRecurring, nil
}
func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
transactionRecurrings := make([]*types.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)
if err != nil {
return nil, err
}
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
accountUuid, err := uuid.Parse(accountId)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", accountId, ErrBadRequest)
}
return nil, types.ErrInternal
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err = tx.SelectContext(ctx, &transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
AND account_id = ?
ORDER BY created_at DESC`,
user.Id, accountUuid)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil {
return nil, err
}
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
user *types.User,
treasureChestId string,
) ([]*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
treasureChestUuid, err := uuid.Parse(treasureChestId)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, ErrBadRequest)
}
return nil, types.ErrInternal
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err = tx.SelectContext(ctx, &transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
AND treasure_chest_id = ?
ORDER BY created_at DESC`,
user.Id, treasureChestUuid)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
return nil, err
}
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User, id string) error {
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
var transactionRecurring types.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)
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)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil {
return err
}
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 := &types.User{
Id: transactionRecurring.UserId,
}
transaction := types.Transaction{
Timestamp: *transactionRecurring.NextExecution,
Party: transactionRecurring.Party,
Description: transactionRecurring.Description,
TreasureChestId: transactionRecurring.TreasureChestId,
Value: transactionRecurring.Value,
}
_, err = s.transaction.Add(ctx, tx, user, transaction)
if err != nil {
return err
}
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", r, err)
if err != nil {
return err
}
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
ctx context.Context,
tx *sqlx.Tx,
oldTransactionRecurring *types.TransactionRecurring,
userId uuid.UUID,
input types.TransactionRecurringInput,
) (*types.TransactionRecurring, error) {
var (
id uuid.UUID
accountUuid *uuid.UUID
treasureChestUuid *uuid.UUID
createdAt time.Time
createdBy uuid.UUID
updatedAt *time.Time
updatedBy uuid.UUID
intervalMonths int64
err error
rowCount int
)
if oldTransactionRecurring == nil {
id, err = s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
createdAt = s.clock.Now()
createdBy = userId
} else {
id = oldTransactionRecurring.Id
createdAt = oldTransactionRecurring.CreatedAt
createdBy = oldTransactionRecurring.CreatedBy
time := s.clock.Now()
updatedAt = &time
updatedBy = userId
}
hasAccount := false
hasTreasureChest := false
if input.AccountId != "" {
temp, err := uuid.Parse(input.AccountId)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
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)
if err != nil {
return nil, err
}
if rowCount == 0 {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
}
hasAccount = true
}
if input.TreasureChestId != "" {
temp, err := uuid.Parse(input.TreasureChestId)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
treasureChestUuid = &temp
var treasureChest 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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
}
return nil, err
}
if treasureChest.ParentId == nil {
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest)
}
hasTreasureChest = true
}
if !hasAccount && !hasTreasureChest {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest)
}
if hasAccount && hasTreasureChest {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", ErrBadRequest)
}
valueFloat, err := strconv.ParseFloat(input.Value, 64)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest)
}
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
if input.Party != "" {
err = validateString(input.Party, "party")
if err != nil {
return nil, err
}
}
if input.Description != "" {
err = validateString(input.Description, "description")
if err != nil {
return nil, err
}
}
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest)
}
if intervalMonths < 1 {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", ErrBadRequest)
}
var nextExecution *time.Time = nil
if input.NextExecution != "" {
t, err := time.Parse("2006-01-02", input.NextExecution)
if err != nil {
slog.ErrorContext(ctx, "transaction validate", "err", err)
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
}
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
nextExecution = &t
}
transactionRecurring := types.TransactionRecurring{
Id: id,
UserId: userId,
IntervalMonths: intervalMonths,
NextExecution: nextExecution,
Party: input.Party,
Description: input.Description,
AccountId: accountUuid,
TreasureChestId: treasureChestUuid,
Value: value,
CreatedAt: createdAt,
CreatedBy: createdBy,
UpdatedAt: updatedAt,
UpdatedBy: &updatedBy,
}
return &transactionRecurring, nil
}

View File

@@ -1,321 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type TreasureChest interface {
Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(ctx context.Context, user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error)
GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error)
Delete(ctx context.Context, user *types.User, id string) error
}
type TreasureChestImpl struct {
db *sqlx.DB
clock Clock
random Random
}
func NewTreasureChest(db *sqlx.DB, random Random, clock Clock) TreasureChest {
return TreasureChestImpl{
db: db,
clock: clock,
random: random,
}
}
func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
err = validateString(name, "name")
if err != nil {
return nil, err
}
var parentUuid *uuid.UUID
if parentId != "" {
parent, err := s.Get(ctx, user, parentId)
if err != nil {
return nil, err
}
if parent.ParentId != nil {
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
}
parentUuid = &parent.Id
}
treasureChest := &types.TreasureChest{
Id: newId,
ParentId: parentUuid,
UserId: user.Id,
Name: name,
CurrentBalance: 0,
CreatedAt: s.clock.Now(),
CreatedBy: user.Id,
UpdatedAt: nil,
UpdatedBy: nil,
}
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)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
err := validateString(name, "name")
if err != nil {
return nil, err
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.ErrorContext(ctx, "treasureChest update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
treasureChest := &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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
}
return nil, types.ErrInternal
}
var parentUuid *uuid.UUID
if parentId != "" {
parent, err := s.Get(ctx, user, parentId)
if err != nil {
return nil, err
}
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)
if err != nil {
return nil, err
}
if parent.ParentId != nil || childCount > 0 {
return nil, fmt.Errorf("only one level allowed: %w", ErrBadRequest)
}
parentUuid = &parent.Id
}
timestamp := s.clock.Now()
treasureChest.Name = name
treasureChest.ParentId = parentUuid
treasureChest.UpdatedAt = &timestamp
treasureChest.UpdatedBy = &user.Id
r, err := tx.NamedExecContext(ctx, `
UPDATE treasure_chest
SET
parent_id = :parent_id,
name = :name,
current_balance = :current_balance,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, treasureChest)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s TreasureChestImpl) Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "treasureChest get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var treasureChest 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)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
}
return nil, types.ErrInternal
}
return &treasureChest, nil
}
func (s TreasureChestImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
treasureChests := make([]*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)
if err != nil {
return nil, err
}
return sortTreasureChests(treasureChests), nil
}
func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr string) error {
if user == nil {
return ErrUnauthorized
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.ErrorContext(ctx, "treasureChest delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
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)
if err != nil {
return err
}
if childCount > 0 {
return fmt.Errorf("treasure chest has children: %w", ErrBadRequest)
}
transactionsCount := 0
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)
if err != nil {
return err
}
if transactionsCount > 0 {
return fmt.Errorf("treasure chest has transactions: %w", ErrBadRequest)
}
recurringCount := 0
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)
if err != nil {
return err
}
if recurringCount > 0 {
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", ErrBadRequest)
}
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
return nil
}
func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
var (
roots []*types.TreasureChest
)
children := make(map[uuid.UUID][]*types.TreasureChest)
result := make([]*types.TreasureChest, 0)
for _, node := range nodes {
if node.ParentId == nil {
roots = append(roots, node)
} else {
children[*node.ParentId] = append(children[*node.ParentId], node)
}
}
slices.SortFunc(roots, func(a, b *types.TreasureChest) int {
return compareStrings(a.Name, b.Name)
})
for _, root := range roots {
result = append(result, root)
childList := children[root.Id]
slices.SortFunc(childList, func(a, b *types.TreasureChest) int {
return compareStrings(a.Name, b.Name)
})
result = append(result, childList...)
}
return result
}
func compareStrings(a, b string) int {
if a == b {
return 0
}
if a < b {
return -1
}
return 1
}

View File

@@ -1,32 +0,0 @@
package dashboard
import "spend-sparrow/internal/types"
templ Dashboard(treasureChests []*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>
<section>
<form class="flex items-center justify-end gap-4 mr-40">
<label for="treasure-chest">Treasure Chest:</label>
<select id="treasure-chest-id" name="treasure-chest-id" class="bg-white input">
<option value="">- Select Treasure Chest -</option>
for _, parent := range treasureChests {
if parent.ParentId == nil {
<optgroup label={ parent.Name }>
for _, child := range treasureChests {
if child.ParentId != nil && *child.ParentId == parent.Id {
<option
value={ child.Id.String() }
>{ child.Name }</option>
}
}
</optgroup>
}
}
</select>
</form>
<div id="treasure-chest" class="h-96 mt-10"></div>
</section>
</div>
}

View File

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

View File

@@ -1,95 +0,0 @@
package template
import "spend-sparrow/internal/template/svg"
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"
if isActive {
return common + " " + "underline"
}
return common + " " + "hover:underline"
}
templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>SpendSparrow</title>
<link rel="icon" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/css/tailwind.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta
name="htmx-config"
content='{
"includeIndicatorStyles": false,
"selfRequestsOnly": true,
"allowScriptTags": false
}'
/>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/toast.js"></script>
<script src="/static/js/layout.js"></script>
<script src="/static/js/transaction.js"></script>
<script src="/static/js/time.js"></script>
<script src="/static/js/echarts.min.js"></script>
<script src="/static/js/dashboard.js" defer></script>
</head>
<body hx-headers='{"Csrf-Token": "CSRF_TOKEN"}'>
<div class="flex flex-col min-h-screen">
<header class="sticky top-0 z-50 bg-white flex items-center gap-6 p-4 border-b-1 border-gray-200">
<button id="menuButton" class="w-10 h-10 block xl:hidden">
@svg.Menu()
</button>
<a href="/" class="flex gap-2 -mt-2">
<img width="150" src="/static/logo.svg" alt="SpendSparrow logo"/>
</a>
<div class="ml-auto">
@user
</div>
</header>
// Content
<div class="flex flex-1">
if loggedIn {
<aside class="shrink-0 h-[calc(100vh-4rem)] xl:block hidden sticky top-18 border-r-1 border-gray-200 overflow-y-auto p-4">
@navigation(path)
</aside>
}
<main class="flex-1 p-6">
if slot != nil {
@slot
}
</main>
</div>
</div>
<dialog id="menu" class="max-h-none w-64 h-screen">
<header class="sticky top-0 z-50 bg-white flex items-center justify-between p-4 border-b-1 border-gray-200">
<a href="/" class="flex gap-2 -mt-2">
<img width="150" src="/static/logo.svg" alt="SpendSparrow logo"/>
</a>
<button id="menuButtonClose" class="h-6 w-6">
@svg.Cancel()
</button>
</header>
@navigation(path)
</dialog>
<div id="toasts" class="fixed bottom-4 right-4 ml-4 max-w-96 flex flex-col gap-2 z-50">
<div
id="toast"
class="transition-all duration-300
opacity-0 px-4 py-2 text-lg hidden text-bold rounded bg-amber-900 text-white"
></div>
</div>
</body>
</html>
}
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>
</nav>
}

View File

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

View File

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

View File

@@ -1,210 +0,0 @@
package transaction_recurring
import "fmt"
import "time"
import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types"
templ TransactionRecurringItems(transactionsRecurring []*types.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>
<span class="text-sm text-gray-500">Party</span>
<span class="text-sm text-gray-500">Description</span>
<span class="text-sm text-gray-500">Interval</span>
<span class="text-sm text-right text-gray-500">Value</span>
<span></span>
if editId == "new" {
@EditTransactionRecurring(nil, accountId, treasureChestId)
}
for _, transaction := range transactionsRecurring {
if transaction.Id.String() == editId {
@EditTransactionRecurring(transaction, accountId, treasureChestId)
} else {
@TransactionRecurringItem(transaction, accountId, treasureChestId)
}
}
</div>
}
templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
<p class="text-gray-600">
if transactionRecurring.NextExecution != nil {
{ transactionRecurring.NextExecution.Format("2006/01") }
} else {
-
}
</p>
<p class="text-gray-600">
if transactionRecurring.Party != "" {
{ transactionRecurring.Party }
} else {
-
}
</p>
<p class="text-gray-600">
if transactionRecurring.Description != "" {
{ transactionRecurring.Description }
} else {
-
}
</p>
<p class="text-gray-500 text-sm">
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>
} else {
<p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
}
<div class="flex gap-2">
<button
hx-get={ "/transaction-recurring?id=" + transactionRecurring.Id.String() + "&account-id=" + accountId + "&treasure-chest-id=" + treasureChestId + "&edit=true" }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/transaction-recurring/" + transactionRecurring.Id.String() + "?account-id=" + accountId + "&treasure-chest-id=" + treasureChestId }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this transaction?"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
}
templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
{{
var (
id string
)
party := ""
description := ""
value := "0.00"
intervalMonths := "1"
nextExecution := ""
if transactionRecurring == nil {
id = "new"
nextExecution = time.Now().Format("2006-01-02")
} else {
intervalMonths = fmt.Sprintf("%d", transactionRecurring.IntervalMonths)
if transactionRecurring.NextExecution != nil {
nextExecution = transactionRecurring.NextExecution.Format("2006-01-02")
}
party = transactionRecurring.Party
description = transactionRecurring.Description
value = formatFloat(transactionRecurring.Value)
id = transactionRecurring.Id.String()
}
}}
<form
id="transaction-recurring-form"
hx-post={ "/transaction-recurring/" + id }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
class="hidden"
></form>
<input
name="next-execution"
form="transaction-recurring-form"
type="date"
value={ nextExecution }
class="bg-white input"
/>
<input
autofocus
form="transaction-recurring-form"
name="party"
type="text"
value={ party }
size="5"
class="bg-white input"
/>
<input
name="description"
form="transaction-recurring-form"
type="text"
value={ description }
size="10"
class="bg-white input"
/>
<input
name="interval-months"
form="transaction-recurring-form"
type="number"
value={ intervalMonths }
size="1"
class="bg-white input"
/>
<input
name="value"
form="transaction-recurring-form"
step="0.01"
type="number"
size="1"
value={ value }
class="bg-white input"
/>
if accountId != "" {
<input
form="transaction-recurring-form"
type="text"
name="account-id"
class="hidden text-sm text-gray-500"
value={ accountId }
/>
}
if treasureChestId != "" {
<input
form="transaction-recurring-form"
type="text"
name="treasure-chest-id"
class="hidden text-sm text-gray-500"
value={ treasureChestId }
/>
}
<div class="flex gap-2">
<button
form="transaction-recurring-form"
type="submit"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Save()
<span>
Save
</span>
</button>
<button
form="transaction-recurring-form"
hx-get={ "/transaction-recurring?account-id=" + accountId + "&treasure-chest-id=" + treasureChestId }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</button>
</div>
}
func formatFloat(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}

View File

@@ -1,190 +0,0 @@
package treasurechest
import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types"
import "github.com/google/uuid"
templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.UUID]int64) {
<div class="max-w-6xl mt-10 mx-auto">
<button
hx-get="/treasurechest/new"
hx-target="#treasurechest-items"
hx-swap="afterbegin"
class="ml-auto text-center button button-primary px-2 flex items-center gap-2"
>
@svg.Plus()
New Treasure Chest
</button>
<div id="treasurechest-items" class="my-6 flex flex-col">
for _, treasureChest := range treasureChests {
@TreasureChestItem(treasureChest, monthlySums)
}
</div>
</div>
}
templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest, transactionsRecurring templ.Component) {
{{
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"
}
cancelUrl = "/treasurechest/" + id
}
}}
<div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<form
hx-post={ "/treasurechest/" + id }
hx-target={ "#treasurechest-" + id }
hx-swap="outerHTML"
class="text-xl flex justify-end gap-4 items-center"
>
<div class="grow grid grid-cols-[auto_1fr] items-center gap-4">
<label for="name" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
value={ name }
placeholder="Treasure Chest Name"
class="bg-white input max-w-96"
/>
<label for="parent-id" class="text-sm text-gray-500">Parent</label>
<select name="parent-id" class="mr-auto bg-white input">
<option value="" class="text-gray-500">-</option>
for _, parent := range filterNoChildNoSelf(parents, id) {
<option
selected?={ parentId == parent.Id }
value={ parent.Id.String() }
>{ parent.Name }</option>
}
</select>
</div>
<button type="submit" class="button button-neglect px-1 flex items-center gap-2">
@svg.Save()
<span>
Save
</span>
</button>
<button
hx-get={ cancelUrl }
hx-target={ "#treasurechest-" + id }
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</button>
</form>
if id != "new" {
<div class="m-10 border-b-gray-400 border-b-1"></div>
<div class="flex">
<h3 class="text-sm text-gray-500">Monthly Transactions</h3>
<button
hx-get={ "/transaction-recurring?id=new&treasure-chest-id=" + id }
hx-target="next #transaction-recurring"
hx-swap="outerHTML"
class="button button-primary ml-auto px-2 flex items-center gap-2"
>
@svg.Plus()
<p>New Monthly Transaction</p>
</button>
</div>
@transactionsRecurring
}
</div>
}
templ TreasureChestItem(treasureChest *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"
}
}}
<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">&nbsp;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>
} else {
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
}
}
<a
href={ templ.URL("/transaction?treasure-chest-id=" + treasureChest.Id.String()) }
class={ "button button-neglect px-1 flex items-center gap-2 " + viewTransactions }
title="View transactions"
>
@svg.Eye()
<span>
View
</span>
</a>
<button
hx-get={ "/treasurechest/" + treasureChest.Id.String() + "?edit=true" }
hx-target={ "#treasurechest-" + treasureChest.Id.String() }
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/treasurechest/" + treasureChest.Id.String() }
hx-target={ "#treasurechest-" + treasureChest.Id.String() }
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
</div>
}
func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.TreasureChest {
var result []*types.TreasureChest
for _, node := range nodes {
if node.ParentId == nil && node.Id.String() != selfId {
result = append(result, node)
}
}
return result
}

View File

@@ -1,30 +0,0 @@
package types
import "time"
type DashboardMonthlySummary struct {
Month time.Time
// Sum of all Transactions with TreasureChests and no Accounts
Savings int64
// Sum of all Transactions with Accounts and no TreasureChests
Income int64
// Sum of all Transactions with Accounts and TreasureChests
Expenses int64
// Income - Expenses
Total int64
SumOfSavings int64
SumOfAccounts int64
}
type DashboardMainChartEntry struct {
Day time.Time
Value int64
Savings int64
}
type DashboardTreasureChest struct {
Name string
Value int64
Children []DashboardTreasureChest
}

View File

@@ -1,36 +0,0 @@
package types
import (
"fmt"
"strings"
)
func FormatEuros(balance int64) string {
prefix := ""
if balance < 0 {
prefix = "- "
balance = -balance
}
n := float64(balance) / 100
s := fmt.Sprintf("%.2f", n) // "1234567.89"
parts := strings.Split(s, ".")
intPart := parts[0]
fracPart := parts[1]
var result strings.Builder
numberOfSeperators := len(intPart) % 3
if numberOfSeperators == 0 {
result.WriteString(intPart)
} else {
for i := range intPart {
if i > 0 && (i-numberOfSeperators)%3 == 0 {
result.WriteString(",")
}
result.WriteByte(intPart[i])
}
}
return prefix + result.String() + "." + fracPart + " €"
}

View File

@@ -1,109 +0,0 @@
package types
import (
"context"
"errors"
"log/slog"
)
var (
ErrMissingConfig = errors.New("missing config")
)
type Settings struct {
Port string
BaseUrl string
Environment string
Smtp *SmtpSettings
}
type SmtpSettings struct {
Host string
Port string
User string
Pass string
FromMail string
FromName string
}
func NewSettingsFromEnv(ctx context.Context, env func(string) string) (*Settings, error) {
var (
smtp *SmtpSettings
err error
)
if env("SMTP_ENABLED") == "true" {
smtp, err = getSmtpSettings(ctx, env)
if err != nil {
return nil, err
}
}
settings := &Settings{
Port: env("PORT"),
BaseUrl: env("BASE_URL"),
Environment: env("ENVIRONMENT"),
Smtp: smtp,
}
if settings.BaseUrl == "" {
slog.ErrorContext(ctx, "BASE_URL must be set")
return nil, ErrMissingConfig
}
if settings.Port == "" {
slog.ErrorContext(ctx, "PORT must be set")
return nil, ErrMissingConfig
}
if settings.Environment == "" {
slog.ErrorContext(ctx, "ENVIRONMENT must be set")
return nil, ErrMissingConfig
}
slog.InfoContext(ctx, "settings read", "BASE_URL", settings.BaseUrl)
slog.InfoContext(ctx, "settings read", "ENVIRONMENT", settings.Environment)
slog.InfoContext(ctx, "settings read", "ENVIRONMENT", settings.Environment)
return settings, nil
}
func getSmtpSettings(ctx context.Context, env func(string) string) (*SmtpSettings, error) {
smtp := SmtpSettings{
Host: env("SMTP_HOST"),
Port: env("SMTP_PORT"),
User: env("SMTP_USER"),
Pass: env("SMTP_PASS"),
FromMail: env("SMTP_FROM_MAIL"),
FromName: env("SMTP_FROM_NAME"),
}
if smtp.Host == "" {
slog.ErrorContext(ctx, "SMTP_HOST must be set")
return nil, ErrMissingConfig
}
if smtp.Port == "" {
slog.ErrorContext(ctx, "SMTP_PORT must be set")
return nil, ErrMissingConfig
}
if smtp.User == "" {
slog.ErrorContext(ctx, "SMTP_USER must be set")
return nil, ErrMissingConfig
}
if smtp.Pass == "" {
slog.ErrorContext(ctx, "SMTP_PASS must be set")
return nil, ErrMissingConfig
}
if smtp.FromMail == "" {
slog.ErrorContext(ctx, "SMTP_FROM_MAIL must be set")
return nil, ErrMissingConfig
}
if smtp.FromName == "" {
slog.ErrorContext(ctx, "SMTP_FROM_NAME must be set")
return nil, ErrMissingConfig
}
return &smtp, nil
}
func IsOtelEnabled(env func(string) string) bool {
return env("OTEL_ENABLED") == "true"
}

View File

@@ -1,55 +0,0 @@
package types
import (
"time"
"github.com/google/uuid"
)
// Transaction is at the center of the application.
//
// Every piece of data should be calculated based on transactions.
// This means potential calculation errors can be fixed later in time.
//
// If it becomes necessary to precalculate snapshots for performance reasons, this can be done in the future.
// But the transaction should always be the source of truth.
//
// There are the following constallations and their explanation:
//
// Account | TreasureChest | Value | Description
// --------|---------------|-------|----------------
// Y | Y | + | Invalid
// Y | Y | - | Expense
// Y | N | + | Deposit
// Y | N | - | Withdrawal (for moving between accounts)
// N | Y | + | Saving
// N | Y | - | Withdrawal (for moving between treasure chests)
// N | N | + | Invalid
// N | N | - | Invalid
type Transaction struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
Timestamp time.Time `db:"timestamp"`
Party string `db:"party"`
Description string `db:"description"`
AccountId *uuid.UUID `db:"account_id"`
TreasureChestId *uuid.UUID `db:"treasure_chest_id"`
Value int64 `db:"value"`
// If an error is present, then the transaction is not valid and should not be used for calculations.
Error *string `db:"error"`
CreatedAt time.Time `db:"created_at"`
// Either a user_id or a transaction_recurring_id
CreatedBy uuid.UUID `db:"created_by"`
UpdatedAt *time.Time `db:"updated_at"`
UpdatedBy *uuid.UUID `db:"updated_by"`
}
type TransactionItemsFilter struct {
AccountId string
TreasureChestId string
Error string
Page string
}

View File

@@ -1,38 +0,0 @@
package types
import (
"time"
"github.com/google/uuid"
)
type TransactionRecurring struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
IntervalMonths int64 `db:"interval_months"`
NextExecution *time.Time `db:"next_execution"`
Party string `db:"party"`
Description string `db:"description"`
AccountId *uuid.UUID `db:"account_id"`
TreasureChestId *uuid.UUID `db:"treasure_chest_id"`
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"`
}
type TransactionRecurringInput struct {
Id string
IntervalMonths string
NextExecution string
Party string
Description string
AccountId string
TreasureChestId string
Value string
}

View File

@@ -1,42 +0,0 @@
package utils
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
)
func TriggerToast(ctx context.Context, w http.ResponseWriter, r *http.Request, class string, message string) {
if IsHtmx(r) {
w.Header().Set("Hx-Trigger", fmt.Sprintf(`{"toast": "%v|%v"}`, class, strings.ReplaceAll(message, `"`, `\"`)))
} else {
slog.ErrorContext(ctx, "Trying to trigger toast in non-HTMX request")
}
}
func TriggerToastWithStatus(ctx context.Context, w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
TriggerToast(ctx, w, r, class, message)
w.WriteHeader(statusCode)
}
func DoRedirect(w http.ResponseWriter, r *http.Request, url string) {
if IsHtmx(r) {
w.Header().Add("Hx-Redirect", url)
} else {
http.Redirect(w, r, url, http.StatusSeeOther)
}
}
func WaitMinimumTime[T any](waitTime time.Duration, f func() (T, error)) (T, error) {
start := time.Now()
result, err := f()
time.Sleep(waitTime - time.Since(start))
return result, err
}
func IsHtmx(r *http.Request) bool {
return r.Header.Get("Hx-Request") == "true"
}

56
log/default.go Normal file
View File

@@ -0,0 +1,56 @@
package log
import (
"fmt"
"log"
"log/slog"
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
errorMetric = promauto.NewCounter(
prometheus.CounterOpts{
Name: "spendsparrow_error_total",
Help: "The total number of errors during processing",
},
)
)
func Fatal(message string, args ...interface{}) {
errorMetric.Inc()
s := format(message, args)
log.Fatal(s)
}
func Error(message string, args ...interface{}) {
errorMetric.Inc()
s := format(message, args)
slog.Error(s)
}
func Warn(message string, args ...interface{}) {
s := format(message, args)
slog.Warn(s)
}
func Info(message string, args ...interface{}) {
s := format(message, args)
slog.Info(s)
}
func format(message string, args []interface{}) string {
var w strings.Builder
if len(args) > 0 {
fmt.Fprintf(&w, message, args...)
} else {
w.WriteString(message)
}
return w.String()
}

146
main.go
View File

@@ -1,41 +1,145 @@
package main
import (
"context"
"log/slog"
"os"
"spend-sparrow/internal"
"spend-sparrow/db"
"spend-sparrow/handler"
"spend-sparrow/handler/middleware"
"spend-sparrow/log"
"spend-sparrow/service"
"spend-sparrow/types"
"context"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"github.com/uptrace/opentelemetry-go-extra/otelsql"
"github.com/uptrace/opentelemetry-go-extra/otelsqlx"
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
ctx := context.Background()
err := godotenv.Load()
if err != nil {
slog.ErrorContext(ctx, "Error loading .env file")
return
log.Fatal("Error loading .env file")
}
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
otelsql.WithAttributes(semconv.DBSystemSqlite))
db, err := sqlx.Open("sqlite3", "./data/spend-sparrow.db")
if err != nil {
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
return
log.Fatal("Could not open Database data.db: %v", err)
}
defer func() {
if err = db.Close(); err != nil {
slog.ErrorContext(ctx, "Database close failed", "err", err)
}
err := db.Close()
log.Fatal("Could not close Database data.db: %v", err)
}()
if err = internal.Run(context.Background(), db, "", os.Getenv); err != nil {
slog.ErrorContext(ctx, "Error running server", "err", err)
return
run(context.Background(), db, os.Getenv)
}
func run(ctx context.Context, database *sqlx.DB, env func(string) string) {
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
log.Info("Starting server...")
// init server settings
serverSettings := types.NewSettingsFromEnv(env)
// init db
err := db.RunMigrations(database, "")
if err != nil {
log.Fatal("Could not run migrations: %v", err)
}
// init servers
var prometheusServer *http.Server
if serverSettings.PrometheusEnabled {
prometheusServer := &http.Server{
Addr: ":8081",
Handler: promhttp.Handler(),
}
go startServer(prometheusServer)
}
httpServer := &http.Server{
Addr: ":" + serverSettings.Port,
Handler: createHandler(database, serverSettings),
}
go startServer(httpServer)
// graceful shutdown
var wg sync.WaitGroup
wg.Add(2)
go shutdownServer(httpServer, ctx, &wg)
go shutdownServer(prometheusServer, ctx, &wg)
wg.Wait()
}
func startServer(s *http.Server) {
log.Info("Starting server on %q", s.Addr)
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("error listening and serving: %v", err)
}
}
func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
if s == nil {
return
}
<-ctx.Done()
shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer cancel()
if err := s.Shutdown(shutdownCtx); err != nil {
log.Error("error shutting down http server: %v", err)
} else {
log.Info("Gracefully stopped http server on %v", s.Addr)
}
}
func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
var router = http.NewServeMux()
authDb := db.NewAuthSqlite(d)
randomService := service.NewRandom()
clockService := service.NewClock()
mailService := service.NewMail(serverSettings)
authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings)
accountService := service.NewAccount(d, randomService, clockService, serverSettings)
treasureChestService := service.NewTreasureChest(d, randomService, clockService, serverSettings)
transactionService := service.NewTransaction(d, randomService, clockService, serverSettings)
render := handler.NewRender()
indexHandler := handler.NewIndex(authService, render)
authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, authService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, authService, render)
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, authService, render)
indexHandler.Handle(router)
accountHandler.Handle(router)
treasureChestHandler.Handle(router)
authHandler.Handle(router)
transactionHandler.Handle(router)
// Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
return middleware.Wrapper(
router,
middleware.SecurityHeaders(serverSettings),
middleware.CacheControl,
middleware.CrossSiteRequestForgery(authService),
middleware.Authenticate(authService),
middleware.Gzip,
middleware.Log,
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
UPDATE treasure_chest
SET parent_id = NULL
WHERE parent_id = "00000000-0000-0000-0000-000000000000";

View File

@@ -1,21 +0,0 @@
CREATE TABLE "transaction_recurring" (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
interval_months INTEGER NOT NULL,
last_execution DATETIME,
active INTEGER NOT NULL,
party TEXT,
description TEXT,
account_id TEXT,
treasure_chest_id TEXT,
value INTEGER NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL,
updated_at DATETIME,
updated_by TEXT
) WITHOUT ROWID;

View File

@@ -1,5 +0,0 @@
ALTER TABLE transaction_recurring DROP COLUMN active;
ALTER TABLE transaction_recurring DROP COLUMN last_execution;
ALTER TABLE transaction_recurring ADD COLUMN next_execution DATETIME;

513
package-lock.json generated
View File

@@ -9,32 +9,51 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@tailwindcss/cli": "4.1.16",
"echarts": "6.0.0",
"htmx.org": "2.0.8",
"tailwindcss": "4.1.16"
"@tailwindcss/cli": "4.1.7",
"htmx.org": "2.0.4",
"tailwindcss": "4.1.7"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -47,17 +66,27 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -375,68 +404,73 @@
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.16.tgz",
"integrity": "sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.7.tgz",
"integrity": "sha512-hJNjpov/UiJc9ZWH4j/eEQxqklADrD/71s+t8Y0wbyQVAwtLkSp+MeC/sHTb03X+28rfbe0fRXkiBsf73/IwPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"enhanced-resolve": "^5.18.3",
"@tailwindcss/node": "4.1.7",
"@tailwindcss/oxide": "4.1.7",
"enhanced-resolve": "^5.18.1",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.1.16"
"tailwindcss": "4.1.7"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
"integrity": "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"@ampproject/remapping": "^2.3.0",
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"lightningcss": "1.30.1",
"magic-string": "^0.30.17",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.16"
"tailwindcss": "4.1.7"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.7.tgz",
"integrity": "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.4.3"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
"@tailwindcss/oxide-android-arm64": "4.1.7",
"@tailwindcss/oxide-darwin-arm64": "4.1.7",
"@tailwindcss/oxide-darwin-x64": "4.1.7",
"@tailwindcss/oxide-freebsd-x64": "4.1.7",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.7",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.7",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.7",
"@tailwindcss/oxide-linux-x64-musl": "4.1.7",
"@tailwindcss/oxide-wasm32-wasi": "4.1.7",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.7",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.7"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.7.tgz",
"integrity": "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==",
"cpu": [
"arm64"
],
@@ -451,9 +485,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.7.tgz",
"integrity": "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==",
"cpu": [
"arm64"
],
@@ -468,9 +502,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.7.tgz",
"integrity": "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==",
"cpu": [
"x64"
],
@@ -485,9 +519,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.7.tgz",
"integrity": "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==",
"cpu": [
"x64"
],
@@ -502,9 +536,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.7.tgz",
"integrity": "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==",
"cpu": [
"arm"
],
@@ -519,9 +553,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.7.tgz",
"integrity": "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==",
"cpu": [
"arm64"
],
@@ -536,9 +570,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.7.tgz",
"integrity": "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==",
"cpu": [
"arm64"
],
@@ -553,9 +587,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.7.tgz",
"integrity": "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==",
"cpu": [
"x64"
],
@@ -570,9 +604,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.7.tgz",
"integrity": "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==",
"cpu": [
"x64"
],
@@ -587,9 +621,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.7.tgz",
"integrity": "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -605,30 +639,30 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.9",
"@tybys/wasm-util": "^0.9.0",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.5.0",
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.5.0",
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -638,7 +672,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"version": "1.0.2",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -648,19 +682,19 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"version": "0.2.9",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
"@emnapi/core": "^1.4.0",
"@emnapi/runtime": "^1.4.0",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"version": "0.9.0",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -670,16 +704,16 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
"integrity": "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==",
"cpu": [
"arm64"
],
@@ -694,9 +728,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.7.tgz",
"integrity": "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==",
"cpu": [
"x64"
],
@@ -710,6 +744,16 @@
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -723,6 +767,16 @@
"node": ">=8"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -736,21 +790,10 @@
"node": ">=0.10"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -782,9 +825,9 @@
"license": "ISC"
},
"node_modules/htmx.org": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==",
"dev": true,
"license": "0BSD"
},
@@ -822,9 +865,9 @@
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"license": "MIT",
"bin": {
@@ -832,9 +875,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -848,44 +891,22 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"cpu": [
"arm64"
],
@@ -904,9 +925,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"cpu": [
"x64"
],
@@ -925,9 +946,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"cpu": [
"x64"
],
@@ -946,9 +967,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"cpu": [
"arm"
],
@@ -967,9 +988,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"cpu": [
"arm64"
],
@@ -988,9 +1009,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"cpu": [
"arm64"
],
@@ -1009,9 +1030,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"cpu": [
"x64"
],
@@ -1030,9 +1051,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"cpu": [
"x64"
],
@@ -1051,9 +1072,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"cpu": [
"arm64"
],
@@ -1072,9 +1093,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"cpu": [
"x64"
],
@@ -1093,9 +1114,9 @@
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1103,13 +1124,13 @@
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/micromatch": {
@@ -1126,6 +1147,45 @@
"node": ">=8.6"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"dev": true,
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1174,22 +1234,40 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
"integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tar": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1203,21 +1281,14 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "0BSD"
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}
}

View File

@@ -1,19 +1,18 @@
{
"name": "spend-sparrow",
"version": "1.0.0",
"description": "Personal finance tracking done right",
"description": "Your (almost) independent tech stack to host on a VPC.",
"main": "index.js",
"scripts": {
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tailwindcss/cli": "4.1.16",
"htmx.org": "2.0.8",
"tailwindcss": "4.1.16",
"echarts": "6.0.0"
"htmx.org": "2.0.4",
"tailwindcss": "4.1.7",
"@tailwindcss/cli": "4.1.7"
}
}

242
service/account.go Normal file
View File

@@ -0,0 +1,242 @@
package service
import (
"fmt"
"regexp"
"spend-sparrow/db"
"spend-sparrow/log"
"spend-sparrow/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9äöüß -]+$`)
accountMetric = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "spendsparrow_account_total",
Help: "The total of account operations",
},
[]string{"operation"},
)
)
type Account interface {
Add(user *types.User, name string) (*types.Account, error)
UpdateName(user *types.User, id string, name string) (*types.Account, error)
Get(user *types.User, id string) (*types.Account, error)
GetAll(user *types.User) ([]*types.Account, error)
Delete(user *types.User, id string) error
}
type AccountImpl struct {
db *sqlx.DB
clock Clock
random Random
settings *types.Settings
}
func NewAccount(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) Account {
return AccountImpl{
db: db,
clock: clock,
random: random,
settings: settings,
}
}
func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error) {
accountMetric.WithLabelValues("add").Inc()
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
err = validateString(name, "name")
if err != nil {
return nil, err
}
account := &types.Account{
Id: newId,
UserId: user.Id,
Name: name,
CurrentBalance: 0,
LastTransaction: nil,
OinkBalance: 0,
CreatedAt: s.clock.Now(),
CreatedBy: user.Id,
UpdatedAt: nil,
UpdatedBy: nil,
}
r, err := s.db.NamedExec(`
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("account Insert", r, err)
if err != nil {
return nil, err
}
return account, nil
}
func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*types.Account, error) {
accountMetric.WithLabelValues("update").Inc()
if user == nil {
return nil, ErrUnauthorized
}
err := validateString(name, "name")
if err != nil {
return nil, err
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("account update: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("account Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
var account types.Account
err = tx.Get(&account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("account Update", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
}
return nil, types.ErrInternal
}
timestamp := s.clock.Now()
account.Name = name
account.UpdatedAt = &timestamp
account.UpdatedBy = &user.Id
r, err := tx.NamedExec(`
UPDATE account
SET
name = :name,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, account)
err = db.TransformAndLogDbError("account Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("account Update", nil, err)
if err != nil {
return nil, err
}
return &account, nil
}
func (s AccountImpl) Get(user *types.User, id string) (*types.Account, error) {
accountMetric.WithLabelValues("get").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("account get: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
account := &types.Account{}
err = s.db.Get(account, `
SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("account Get", nil, err)
if err != nil {
log.Error("account get: %v", err)
return nil, err
}
return account, nil
}
func (s AccountImpl) GetAll(user *types.User) ([]*types.Account, error) {
accountMetric.WithLabelValues("get_all").Inc()
if user == nil {
return nil, ErrUnauthorized
}
accounts := make([]*types.Account, 0)
err := s.db.Select(&accounts, `
SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id)
err = db.TransformAndLogDbError("account GetAll", nil, err)
if err != nil {
return nil, err
}
return accounts, nil
}
func (s AccountImpl) Delete(user *types.User, id string) error {
accountMetric.WithLabelValues("delete").Inc()
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("account delete: %v", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("account Delete", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
transactionsCount := 0
err = tx.Get(&transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("account Delete", nil, err)
if err != nil {
return err
}
if transactionsCount > 0 {
return fmt.Errorf("account has transactions, cannot delete: %w", ErrBadRequest)
}
res, err := tx.Exec("DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("account Delete", res, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("account Delete", nil, err)
if err != nil {
return err
}
return nil
}

View File

@@ -4,14 +4,15 @@ import (
"context"
"crypto/subtle"
"errors"
"log/slog"
"net/mail"
"spend-sparrow/internal/db"
mailTemplate "spend-sparrow/internal/template/mail"
"spend-sparrow/internal/types"
"strings"
"time"
"spend-sparrow/db"
"spend-sparrow/log"
mailTemplate "spend-sparrow/template/mail"
"spend-sparrow/types"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
@@ -26,26 +27,24 @@ var (
)
type Auth interface {
SignUp(ctx context.Context, email string, password string) (*types.User, error)
SendVerificationMail(ctx context.Context, userId uuid.UUID, email string)
VerifyUserEmail(ctx context.Context, token string) error
SignUp(email string, password string) (*types.User, error)
SendVerificationMail(userId uuid.UUID, email string)
VerifyUserEmail(token string) error
SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error)
SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error)
SignInAnonymous(ctx context.Context) (*types.Session, error)
SignOut(ctx context.Context, sessionId string) error
SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error)
SignInSession(sessionId string) (*types.Session, *types.User, error)
SignInAnonymous() (*types.Session, error)
SignOut(sessionId string) error
DeleteAccount(ctx context.Context, user *types.User, currPass string) error
DeleteAccount(user *types.User, currPass string) error
ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error
ChangePassword(user *types.User, sessionId string, currPass, newPass string) error
SendForgotPasswordMail(ctx context.Context, email string) error
ForgotPassword(ctx context.Context, token string, newPass string) error
SendForgotPasswordMail(email string) error
ForgotPassword(token string, newPass string) error
IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool
GetCsrfToken(ctx context.Context, session *types.Session) (string, error)
CleanupSessionsAndTokens(ctx context.Context) error
IsCsrfTokenValid(tokenStr string, sessionId string) bool
GetCsrfToken(session *types.Session) (string, error)
}
type AuthImpl struct {
@@ -66,8 +65,8 @@ func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *
}
}
func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) {
user, err := service.db.GetUserByEmail(ctx, email)
func (service AuthImpl) SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error) {
user, err := service.db.GetUserByEmail(email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, nil, ErrInvalidCredentials
@@ -82,49 +81,12 @@ func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, emai
return nil, nil, ErrInvalidCredentials
}
newSession, err := service.createSession(ctx, user.Id)
err = service.cleanUpSessionWithTokens(session)
if err != nil {
return nil, nil, types.ErrInternal
}
err = service.db.DeleteSession(ctx, session.Id)
if err != nil {
return nil, nil, types.ErrInternal
}
tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
if err != nil {
return nil, nil, types.ErrInternal
}
for _, token := range tokens {
err = service.db.DeleteToken(ctx, token.Token)
if err != nil {
return nil, nil, types.ErrInternal
}
}
return newSession, user, nil
}
func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) {
if sessionId == "" {
return nil, nil, ErrSessionIdInvalid
}
session, err := service.db.GetSession(ctx, sessionId)
if err != nil {
return nil, nil, types.ErrInternal
}
if session.ExpiresAt.Before(service.clock.Now()) {
_ = service.db.DeleteSession(ctx, sessionId)
return nil, nil, nil
}
if session.UserId == uuid.Nil {
return session, nil, nil
}
user, err := service.db.GetUser(ctx, session.UserId)
session, err = service.createSession(user.Id)
if err != nil {
return nil, nil, types.ErrInternal
}
@@ -132,18 +94,92 @@ func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*t
return session, user, nil
}
func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, error) {
session, err := service.createSession(ctx, uuid.Nil)
func (service AuthImpl) cleanUpSessionWithTokens(session *types.Session) error {
if session == nil {
return nil
}
err := service.db.DeleteSession(session.Id)
if err != nil {
return types.ErrInternal
}
tokens, err := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
if err != nil {
return types.ErrInternal
}
for _, token := range tokens {
err = service.db.DeleteToken(token.Token)
if err != nil {
return types.ErrInternal
}
}
return nil
}
func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.User, error) {
if sessionId == "" {
return nil, nil, ErrSessionIdInvalid
}
session, err := service.db.GetSession(sessionId)
if err != nil {
return nil, nil, types.ErrInternal
}
if session.ExpiresAt.Before(service.clock.Now()) {
_ = service.db.DeleteSession(sessionId)
return nil, nil, nil
}
if session.UserId == uuid.Nil {
return session, nil, nil
}
user, err := service.db.GetUser(session.UserId)
if err != nil {
return nil, nil, types.ErrInternal
}
return session, user, nil
}
func (service AuthImpl) SignInAnonymous() (*types.Session, error) {
session, err := service.createSession(uuid.Nil)
if err != nil {
return nil, types.ErrInternal
}
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
log.Info("Anonymous session created: %v", session.Id)
return session, nil
}
func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) {
func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error) {
sessionId, err := service.random.String(32)
if err != nil {
return nil, types.ErrInternal
}
err = service.db.DeleteOldSessions(userId)
if err != nil {
return nil, types.ErrInternal
}
createAt := service.clock.Now()
expiresAt := createAt.Add(24 * time.Hour)
session := types.NewSession(sessionId, userId, createAt, expiresAt)
err = service.db.InsertSession(session)
if err != nil {
return nil, types.ErrInternal
}
return session, nil
}
func (service AuthImpl) SignUp(email string, password string) (*types.User, error) {
_, err := mail.ParseAddress(email)
if err != nil {
return nil, ErrInvalidEmail
@@ -153,12 +189,12 @@ func (service AuthImpl) SignUp(ctx context.Context, email string, password strin
return nil, ErrInvalidPassword
}
userId, err := service.random.UUID(ctx)
userId, err := service.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
salt, err := service.random.Bytes(ctx, 16)
salt, err := service.random.Bytes(16)
if err != nil {
return nil, types.ErrInternal
}
@@ -167,9 +203,9 @@ func (service AuthImpl) SignUp(ctx context.Context, email string, password strin
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
err = service.db.InsertUser(ctx, user)
err = service.db.InsertUser(user)
if err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
if err == db.ErrAlreadyExists {
return nil, ErrAccountExists
} else {
return nil, types.ErrInternal
@@ -179,9 +215,10 @@ func (service AuthImpl) SignUp(ctx context.Context, email string, password strin
return user, nil
}
func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) {
tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, types.TokenTypeEmailVerify)
if err != nil && !errors.Is(err, db.ErrNotFound) {
func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
tokens, err := service.db.GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify)
if err != nil && err != db.ErrNotFound {
return
}
@@ -192,20 +229,14 @@ func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UU
}
if token == nil {
newTokenStr, err := service.random.String(ctx, 32)
newTokenStr, err := service.random.String(32)
if err != nil {
return
}
token = types.NewToken(
userId,
"",
newTokenStr,
types.TokenTypeEmailVerify,
service.clock.Now(),
service.clock.Now().Add(24*time.Hour))
token = types.NewToken(userId, "", newTokenStr, types.TokenTypeEmailVerify, service.clock.Now(), service.clock.Now().Add(24*time.Hour))
err = service.db.InsertToken(ctx, token)
err = service.db.InsertToken(token)
if err != nil {
return
}
@@ -214,24 +245,25 @@ func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UU
var w strings.Builder
err = mailTemplate.Register(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &w)
if err != nil {
slog.ErrorContext(ctx, "Could not render welcome email", "err", err)
log.Error("Could not render welcome email: %v", err)
return
}
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
service.mail.SendMail(email, "Welcome to spend-sparrow", w.String())
}
func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
if tokenStr == "" {
return types.ErrInternal
}
token, err := service.db.GetToken(ctx, tokenStr)
token, err := service.db.GetToken(tokenStr)
if err != nil {
return types.ErrInternal
}
user, err := service.db.GetUser(ctx, token.UserId)
user, err := service.db.GetUser(token.UserId)
if err != nil {
return types.ErrInternal
}
@@ -249,21 +281,23 @@ func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) er
user.EmailVerified = true
user.EmailVerifiedAt = &now
err = service.db.UpdateUser(ctx, user)
err = service.db.UpdateUser(user)
if err != nil {
return types.ErrInternal
}
_ = service.db.DeleteToken(ctx, token.Token)
_ = service.db.DeleteToken(token.Token)
return nil
}
func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error {
return service.db.DeleteSession(ctx, sessionId)
func (service AuthImpl) SignOut(sessionId string) error {
return service.db.DeleteSession(sessionId)
}
func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, currPass string) error {
userDb, err := service.db.GetUser(ctx, user.Id)
func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
userDb, err := service.db.GetUser(user.Id)
if err != nil {
return types.ErrInternal
}
@@ -273,17 +307,18 @@ func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, cur
return ErrInvalidCredentials
}
err = service.db.DeleteUser(ctx, user.Id)
err = service.db.DeleteUser(user.Id)
if err != nil {
return err
}
service.mail.SendMail(ctx, user.Email, "Account deleted", "Your account has been deleted")
service.mail.SendMail(user.Email, "Account deleted", "Your account has been deleted")
return nil
}
func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error {
func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currPass, newPass string) error {
if !isPasswordValid(newPass) {
return ErrInvalidPassword
}
@@ -301,18 +336,18 @@ func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, se
newHash := GetHashPassword(newPass, user.Salt)
user.Password = newHash
err := service.db.UpdateUser(ctx, user)
err := service.db.UpdateUser(user)
if err != nil {
return err
}
sessions, err := service.db.GetSessions(ctx, user.Id)
sessions, err := service.db.GetSessions(user.Id)
if err != nil {
return types.ErrInternal
}
for _, s := range sessions {
if s.Id != sessionId {
err = service.db.DeleteSession(ctx, s.Id)
err = service.db.DeleteSession(s.Id)
if err != nil {
return types.ErrInternal
}
@@ -322,30 +357,24 @@ func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, se
return nil
}
func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string) error {
tokenStr, err := service.random.String(ctx, 32)
func (service AuthImpl) SendForgotPasswordMail(email string) error {
tokenStr, err := service.random.String(32)
if err != nil {
return err
}
user, err := service.db.GetUserByEmail(ctx, email)
user, err := service.db.GetUserByEmail(email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
if err == db.ErrNotFound {
return nil
} else {
return types.ErrInternal
}
}
token := types.NewToken(
user.Id,
"",
tokenStr,
types.TokenTypePasswordReset,
service.clock.Now(),
service.clock.Now().Add(15*time.Minute))
token := types.NewToken(user.Id, "", tokenStr, types.TokenTypePasswordReset, service.clock.Now(), service.clock.Now().Add(15*time.Minute))
err = service.db.InsertToken(ctx, token)
err = service.db.InsertToken(token)
if err != nil {
return types.ErrInternal
}
@@ -353,25 +382,26 @@ func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string
var mail strings.Builder
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
if err != nil {
slog.ErrorContext(ctx, "Could not render reset password email", "err", err)
log.Error("Could not render reset password email: %v", err)
return types.ErrInternal
}
service.mail.SendMail(ctx, email, "Reset Password", mail.String())
service.mail.SendMail(email, "Reset Password", mail.String())
return nil
}
func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
if !isPasswordValid(newPass) {
return ErrInvalidPassword
}
token, err := service.db.GetToken(ctx, tokenStr)
token, err := service.db.GetToken(tokenStr)
if err != nil {
return ErrTokenInvalid
}
err = service.db.DeleteToken(ctx, tokenStr)
err = service.db.DeleteToken(tokenStr)
if err != nil {
return err
}
@@ -381,27 +411,27 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
return ErrTokenInvalid
}
user, err := service.db.GetUser(ctx, token.UserId)
user, err := service.db.GetUser(token.UserId)
if err != nil {
slog.ErrorContext(ctx, "Could not get user from token", "err", err)
log.Error("Could not get user from token: %v", err)
return types.ErrInternal
}
passHash := GetHashPassword(newPass, user.Salt)
user.Password = passHash
err = service.db.UpdateUser(ctx, user)
err = service.db.UpdateUser(user)
if err != nil {
return err
}
sessions, err := service.db.GetSessions(ctx, user.Id)
sessions, err := service.db.GetSessions(user.Id)
if err != nil {
return types.ErrInternal
}
for _, session := range sessions {
err = service.db.DeleteSession(ctx, session.Id)
err = service.db.DeleteSession(session.Id)
if err != nil {
return types.ErrInternal
}
@@ -410,8 +440,8 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
return nil
}
func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
token, err := service.db.GetToken(ctx, tokenStr)
func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool {
token, err := service.db.GetToken(tokenStr)
if err != nil {
return false
}
@@ -419,83 +449,46 @@ func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, s
if token.Type != types.TokenTypeCsrf ||
token.SessionId != sessionId ||
token.ExpiresAt.Before(service.clock.Now()) {
return false
}
return true
}
func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session) (string, error) {
func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
if session == nil {
return "", types.ErrInternal
}
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
if len(tokens) > 0 {
return tokens[0].Token, nil
}
tokenStr, err := service.random.String(ctx, 32)
tokenStr, err := service.random.String(32)
if err != nil {
return "", types.ErrInternal
}
token := types.NewToken(
session.UserId,
session.Id,
tokenStr,
types.TokenTypeCsrf,
service.clock.Now(),
service.clock.Now().Add(8*time.Hour))
err = service.db.InsertToken(ctx, token)
token := types.NewToken(session.UserId, session.Id, tokenStr, types.TokenTypeCsrf, service.clock.Now(), service.clock.Now().Add(8*time.Hour))
err = service.db.InsertToken(token)
if err != nil {
return "", types.ErrInternal
}
slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr)
log.Info("CSRF-Token created: %v", tokenStr)
return tokenStr, nil
}
func (service AuthImpl) CleanupSessionsAndTokens(ctx context.Context) error {
err := service.db.DeleteOldSessions(ctx)
if err != nil {
return types.ErrInternal
}
err = service.db.DeleteOldTokens(ctx)
if err != nil {
return types.ErrInternal
}
return nil
}
func (service AuthImpl) createSession(ctx context.Context, userId uuid.UUID) (*types.Session, error) {
sessionId, err := service.random.String(ctx, 32)
if err != nil {
return nil, types.ErrInternal
}
createAt := service.clock.Now()
expiresAt := createAt.Add(24 * time.Hour)
session := types.NewSession(sessionId, userId, createAt, expiresAt)
err = service.db.InsertSession(ctx, session)
if err != nil {
return nil, types.ErrInternal
}
return session, nil
}
func GetHashPassword(password string, salt []byte) []byte {
return argon2.IDKey([]byte(password), salt, 1, 64*1024, 1, 16)
}
func isPasswordValid(password string) bool {
if len(password) < 8 ||
!strings.ContainsAny(password, "0123456789") ||
!strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") ||

138
service/auth_test.go Normal file
View File

@@ -0,0 +1,138 @@
package service
import (
"spend-sparrow/db"
"spend-sparrow/mocks"
"spend-sparrow/types"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestSignUp(t *testing.T) {
t.Parallel()
t.Run("should check for correct email address", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignUp("invalid email address", "SomeStrongPassword123!")
assert.Equal(t, ErrInvalidEmail, err)
})
t.Run("should check for password complexity", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
weakPasswords := []string{
"123!ab", // too short
"no_upper_case_123",
"NO_LOWER_CASE_123",
"noSpecialChar123",
}
for _, password := range weakPasswords {
_, err := underTest.SignUp("some@valid.email", password)
assert.Equal(t, ErrInvalidPassword, err)
}
})
t.Run("should signup correctly", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
userId := uuid.New()
email := "mail@mail.de"
password := "SomeStrongPassword123!"
salt := []byte("salt")
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
expected := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(userId, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
actual, err := underTest.SignUp(email, password)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
})
t.Run("should return ErrAccountExists", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
userId := uuid.New()
email := "some@valid.email"
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
password := "SomeStrongPassword123!"
salt := []byte("salt")
user := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(user.Id, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignUp(user.Email, password)
assert.Equal(t, ErrAccountExists, err)
})
}
func TestSendVerificationMail(t *testing.T) {
t.Parallel()
t.Run("should use stored token and send mail", func(t *testing.T) {
t.Parallel()
token := types.NewToken(uuid.New(), "sessionId", "someRandomTokenToUse", types.TokenTypeEmailVerify, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
tokens := []*types.Token{token}
email := "some@email.de"
userId := uuid.New()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
mockMail.EXPECT().SendMail(email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool {
return strings.Contains(message, token.Token)
})).Return()
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest.SendVerificationMail(userId, email)
})
}

View File

@@ -13,5 +13,5 @@ func NewClock() Clock {
}
func (c *ClockImpl) Now() time.Time {
return time.Now().UTC()
return time.Now()
}

View File

@@ -1,25 +1,13 @@
package service
import (
"fmt"
"regexp"
)
const (
DECIMALS_MULTIPLIER = 100
)
var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`)
)
import "fmt"
func validateString(value string, fieldName string) error {
switch {
case value == "":
if value == "" {
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)
case !safeInputRegex.MatchString(value):
} else if !safeInputRegex.MatchString(value) {
return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, ErrBadRequest)
default:
} else {
return nil
}
}

44
service/mail.go Normal file
View File

@@ -0,0 +1,44 @@
package service
import (
"spend-sparrow/log"
"spend-sparrow/types"
"fmt"
"net/smtp"
)
type Mail interface {
// Sending an email is a fire and forget operation. Thus no error handling
SendMail(to string, subject string, message string)
}
type MailImpl struct {
server *types.Settings
}
func NewMail(server *types.Settings) MailImpl {
return MailImpl{server: server}
}
func (m MailImpl) SendMail(to string, subject string, message string) {
go m.internalSendMail(to, subject, message)
}
func (m MailImpl) internalSendMail(to string, subject string, message string) {
if m.server.Smtp == nil {
return
}
s := m.server.Smtp
auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
msg := fmt.Sprintf("From: %v <%v>\nTo: %v\nSubject: %v\nMIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n%v", s.FromName, s.FromMail, to, subject, message)
log.Info("Sending mail to %v", to)
err := smtp.SendMail(s.Host+":"+s.Port, auth, s.FromMail, []string{to}, []byte(msg))
if err != nil {
log.Error("Error sending mail: %v", err)
}
}

8
service/money.go Normal file
View File

@@ -0,0 +1,8 @@
package service
type MoneyImpl struct {
}
func NewMoneyImpl() *MoneyImpl {
return &MoneyImpl{}
}

80
service/money_test.go Normal file
View File

@@ -0,0 +1,80 @@
package service
import (
"testing"
)
func TestMoneyCalculation(t *testing.T) {
t.Parallel()
t.Run("should calculate correct oink balance", func(t *testing.T) {
// t.Parallel()
//
// underTest := NewMoneyImpl()
//
// // GIVEN
// timestamp := time.Date(2020, 01, 01, 0, 0, 0, 0, time.UTC)
//
// userId := uuid.New()
//
// account := types.Account{
// Id: uuid.New(),
// UserId: userId,
//
// Type: "Bank",
// Name: "Bank",
//
// CurrentBalance: 0,
// LastTransaction: time.Time{},
// OinkBalance: 0,
// }
//
// // The PiggyBank is a fictional account. The money it "holds" is actually in the Account
// piggyBank := types.PiggyBank{
// Id: uuid.New(),
// UserId: userId,
//
// AccountId: account.Id,
// Name: "Car",
//
// CurrentBalance: 0,
// }
//
// savingsPlan := types.SavingsPlan{
// Id: uuid.New(),
// UserId: userId,
// PiggyBankId: piggyBank.Id,
//
// MonthlySaving: 10,
//
// ValidFrom: timestamp,
// }
//
// transaction1 := types.Transaction{
// Id: uuid.New(),
// UserId: userId,
//
// AccountId: account.Id,
//
// Value: 20,
// Timestamp: timestamp,
// }
//
// transaction2 := types.Transaction{
// Id: uuid.New(),
// UserId: userId,
//
// AccountId: account.Id,
// PiggyBankId: &piggyBank.Id,
//
// Value: -1,
// Timestamp: timestamp.Add(1 * time.Hour),
// }
//
// // WHEN
// actual, err := underTest.CalculateAllBalancesInTime(account, piggyBank, savingsPlan, []types.Transaction{transaction1, transaction2})
//
// // THEN
// assert.Nil(t, err)
// assert.ElementsMatch(t, expected, actual)
})
}

View File

@@ -0,0 +1,55 @@
package service
import (
"spend-sparrow/log"
"spend-sparrow/types"
"crypto/rand"
"encoding/base64"
"github.com/google/uuid"
)
type Random interface {
Bytes(size int) ([]byte, error)
String(size int) (string, error)
UUID() (uuid.UUID, error)
}
type RandomImpl struct {
}
func NewRandom() *RandomImpl {
return &RandomImpl{}
}
func (r *RandomImpl) Bytes(size int) ([]byte, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
log.Error("Error generating random bytes: %v", err)
return []byte{}, types.ErrInternal
}
return b, nil
}
func (r *RandomImpl) String(size int) (string, error) {
bytes, err := r.Bytes(size)
if err != nil {
log.Error("Error generating random string: %v", err)
return "", types.ErrInternal
}
return base64.StdEncoding.EncodeToString(bytes), nil
}
func (r *RandomImpl) UUID() (uuid.UUID, error) {
id, err := uuid.NewRandom()
if err != nil {
log.Error("Error generating random UUID: %v", err)
return uuid.Nil, types.ErrInternal
}
return id, nil
}

567
service/transaction.go Normal file
View File

@@ -0,0 +1,567 @@
package service
import (
"fmt"
"strconv"
"time"
"spend-sparrow/db"
"spend-sparrow/log"
"spend-sparrow/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
transactionMetric = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "spendsparrow_transaction_total",
Help: "The total of transaction operations",
},
[]string{"operation"},
)
)
type Transaction interface {
Add(user *types.User, transaction types.TransactionInput) (*types.Transaction, error)
Update(user *types.User, transaction types.TransactionInput) (*types.Transaction, error)
Get(user *types.User, id string) (*types.Transaction, error)
GetAll(user *types.User) ([]*types.Transaction, error)
Delete(user *types.User, id string) error
RecalculateBalances(user *types.User) error
}
type TransactionImpl struct {
db *sqlx.DB
clock Clock
random Random
settings *types.Settings
}
func NewTransaction(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) Transaction {
return TransactionImpl{
db: db,
clock: clock,
random: random,
settings: settings,
}
}
func (s TransactionImpl) Add(user *types.User, transactionInput types.TransactionInput) (*types.Transaction, error) {
transactionMetric.WithLabelValues("add").Inc()
if user == nil {
return nil, ErrUnauthorized
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction Add", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transaction, err := s.validateAndEnrichTransaction(tx, nil, user.Id, transactionInput)
if err != nil {
return nil, err
}
r, err := tx.NamedExec(`
INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp, 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("transaction Insert", r, err)
if err != nil {
return nil, err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err = tx.Exec(`
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Add", r, err)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err = tx.Exec(`
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Add", r, err)
if err != nil {
return nil, err
}
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction Add", nil, err)
if err != nil {
return nil, err
}
return transaction, nil
}
func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) (*types.Transaction, error) {
transactionMetric.WithLabelValues("update").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(input.Id)
if err != nil {
log.Error("transaction update: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transaction := &types.Transaction{}
err = tx.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transaction Update", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest)
}
return nil, types.ErrInternal
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.Exec(`
UPDATE account
SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.Exec(`
UPDATE treasure_chest
SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
if err != nil {
return nil, err
}
}
transaction, err = s.validateAndEnrichTransaction(tx, transaction, user.Id, input)
if err != nil {
return nil, err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.Exec(`
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.Exec(`
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
if err != nil {
return nil, err
}
}
r, err := tx.NamedExec(`
UPDATE "transaction"
SET
account_id = :account_id,
treasure_chest_id = :treasure_chest_id,
value = :value,
timestamp = :timestamp,
party = :party,
description = :description,
error = :error,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, transaction)
err = db.TransformAndLogDbError("transaction Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction Update", nil, err)
if err != nil {
return nil, err
}
return transaction, nil
}
func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, error) {
transactionMetric.WithLabelValues("get").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("transaction get: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var transaction types.Transaction
err = s.db.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transaction Get", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest)
}
return nil, types.ErrInternal
}
return &transaction, nil
}
func (s TransactionImpl) GetAll(user *types.User) ([]*types.Transaction, error) {
transactionMetric.WithLabelValues("get_all").Inc()
if user == nil {
return nil, ErrUnauthorized
}
transactions := make([]*types.Transaction, 0)
err := s.db.Select(&transactions, `SELECT * FROM "transaction" WHERE user_id = ? ORDER BY timestamp DESC`, user.Id)
err = db.TransformAndLogDbError("transaction GetAll", nil, err)
if err != nil {
return nil, err
}
return transactions, nil
}
func (s TransactionImpl) Delete(user *types.User, id string) error {
transactionMetric.WithLabelValues("delete").Inc()
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("transaction delete: %v", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
var transaction types.Transaction
err = tx.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transaction Delete", nil, err)
if err != nil {
return err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.Exec(`
UPDATE account
SET current_balance = current_balance - ?
WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
if err != nil && err != db.ErrNotFound {
return err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.Exec(`
UPDATE treasure_chest
SET current_balance = current_balance - ?
WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
if err != nil && err != db.ErrNotFound {
return err
}
}
r, err := tx.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction Delete", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionImpl) RecalculateBalances(user *types.User) error {
transactionMetric.WithLabelValues("recalculate").Inc()
if user == nil {
return ErrUnauthorized
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
r, err := tx.Exec(`
UPDATE account
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil && err != db.ErrNotFound {
return err
}
r, err = tx.Exec(`
UPDATE treasure_chest
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil && err != db.ErrNotFound {
return err
}
rows, err := tx.Queryx(`
SELECT *
FROM "transaction"
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
if err != nil && err != db.ErrNotFound {
return err
}
defer func() {
err := rows.Close()
if err != nil {
log.Error("transaction RecalculateBalances: %v", err)
}
}()
transaction := &types.Transaction{}
for rows.Next() {
err = rows.StructScan(transaction)
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
updateErrors(transaction)
r, err = tx.Exec(`
UPDATE "transaction"
SET error = ?
WHERE user_id = ?
AND id = ?`, transaction.Error, user.Id, transaction.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil {
return err
}
if transaction.Error != nil {
continue
}
if transaction.AccountId != nil {
r, err = tx.Exec(`
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil {
return err
}
}
if transaction.TreasureChestId != nil {
r, err = tx.Exec(`
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil {
return err
}
}
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) {
var (
id uuid.UUID
accountUuid *uuid.UUID
treasureChestUuid *uuid.UUID
createdAt time.Time
createdBy uuid.UUID
updatedAt *time.Time
updatedBy uuid.UUID
err error
rowCount int
)
if oldTransaction == nil {
id, err = s.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
createdAt = s.clock.Now()
createdBy = userId
} else {
id = oldTransaction.Id
createdAt = oldTransaction.CreatedAt
createdBy = oldTransaction.CreatedBy
time := s.clock.Now()
updatedAt = &time
updatedBy = userId
}
if input.AccountId != "" {
temp, err := uuid.Parse(input.AccountId)
if err != nil {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
accountUuid = &temp
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
err = db.TransformAndLogDbError("transaction validate", nil, err)
if err != nil {
return nil, err
}
if rowCount == 0 {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
}
}
if input.TreasureChestId != "" {
temp, err := uuid.Parse(input.TreasureChestId)
if err != nil {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
treasureChestUuid = &temp
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
err = db.TransformAndLogDbError("transaction validate", nil, err)
if err != nil {
return nil, err
}
if rowCount == 0 {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
}
}
valueFloat, err := strconv.ParseFloat(input.Value, 64)
if err != nil {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest)
}
valueInt := int64(valueFloat * 100)
timestamp, err := time.Parse("2006-01-02", input.Timestamp)
if err != nil {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
}
if input.Party != "" {
err = validateString(input.Party, "party")
if err != nil {
return nil, err
}
}
if input.Description != "" {
err = validateString(input.Description, "description")
if err != nil {
return nil, err
}
}
transaction := types.Transaction{
Id: id,
UserId: userId,
AccountId: accountUuid,
TreasureChestId: treasureChestUuid,
Value: valueInt,
Timestamp: timestamp,
Party: input.Party,
Description: input.Description,
CreatedAt: createdAt,
CreatedBy: createdBy,
UpdatedAt: updatedAt,
UpdatedBy: &updatedBy,
}
updateErrors(&transaction)
return &transaction, nil
}
func updateErrors(transaction *types.Transaction) {
error := ""
if transaction.Value < 0 {
if transaction.AccountId == nil {
error = "no account specified"
} else if transaction.TreasureChestId == nil {
error = "no treasure chest specified"
}
} else if transaction.Value > 0 {
if transaction.AccountId == nil && transaction.TreasureChestId == nil {
error = "either an account or a treasure chest needs to be specified"
} else if transaction.AccountId != nil && transaction.TreasureChestId != nil {
error = "positive amounts can only be applied to either an account or a treasure chest"
}
} else {
error = "\"value\" needs to be specified"
}
if error == "" {
transaction.Error = nil
} else {
transaction.Error = &error
}
}

328
service/treasure_chest.go Normal file
View File

@@ -0,0 +1,328 @@
package service
import (
"fmt"
"slices"
"spend-sparrow/db"
"spend-sparrow/log"
"spend-sparrow/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
treasureChestMetric = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "spendsparrow_treasurechest_total",
Help: "The total of treasurechest operations",
},
[]string{"operation"},
)
)
type TreasureChest interface {
Add(user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(user *types.User, id string) (*types.TreasureChest, error)
GetAll(user *types.User) ([]*types.TreasureChest, error)
Delete(user *types.User, id string) error
}
type TreasureChestImpl struct {
db *sqlx.DB
clock Clock
random Random
settings *types.Settings
}
func NewTreasureChest(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) TreasureChest {
return TreasureChestImpl{
db: db,
clock: clock,
random: random,
settings: settings,
}
}
func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("add").Inc()
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
err = validateString(name, "name")
if err != nil {
return nil, err
}
parentUuid := uuid.Nil
if parentId != "" {
parent, err := s.Get(user, parentId)
if err != nil {
return nil, err
}
if parent.ParentId != uuid.Nil {
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
}
parentUuid = parent.Id
}
treasureChest := &types.TreasureChest{
Id: newId,
ParentId: parentUuid,
UserId: user.Id,
Name: name,
CurrentBalance: 0,
CreatedAt: s.clock.Now(),
CreatedBy: user.Id,
UpdatedAt: nil,
UpdatedBy: nil,
}
r, err := s.db.NamedExec(`
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("treasureChest Insert", r, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("update").Inc()
if user == nil {
return nil, ErrUnauthorized
}
err := validateString(name, "name")
if err != nil {
return nil, err
}
id, err := uuid.Parse(idStr)
if err != nil {
log.Error("treasureChest update: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
treasureChest := &types.TreasureChest{}
err = tx.Get(treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
}
return nil, types.ErrInternal
}
parentUuid := uuid.Nil
if parentId != "" {
parent, err := s.Get(user, parentId)
if err != nil {
return nil, err
}
var childCount int
err = tx.Get(&childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
return nil, err
}
if parent.ParentId != uuid.Nil || childCount > 0 {
return nil, fmt.Errorf("only one level allowed: %w", ErrBadRequest)
}
parentUuid = parent.Id
}
timestamp := s.clock.Now()
treasureChest.Name = name
treasureChest.ParentId = parentUuid
treasureChest.UpdatedAt = &timestamp
treasureChest.UpdatedBy = &user.Id
r, err := tx.NamedExec(`
UPDATE treasure_chest
SET
parent_id = :parent_id,
name = :name,
current_balance = :current_balance,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, treasureChest)
err = db.TransformAndLogDbError("treasureChest Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s TreasureChestImpl) Get(user *types.User, id string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("get").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("treasureChest get: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
treasureChest := &types.TreasureChest{}
err = s.db.Get(treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("treasureChest Get", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
}
return nil, types.ErrInternal
}
return treasureChest, nil
}
func (s TreasureChestImpl) GetAll(user *types.User) ([]*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("get_all").Inc()
if user == nil {
return nil, ErrUnauthorized
}
treasureChests := make([]*types.TreasureChest, 0)
err := s.db.Select(&treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("treasureChest GetAll", nil, err)
if err != nil {
return nil, err
}
return sortTree(treasureChests), nil
}
func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
treasureChestMetric.WithLabelValues("delete").Inc()
if user == nil {
return ErrUnauthorized
}
id, err := uuid.Parse(idStr)
if err != nil {
log.Error("treasureChest delete: %v", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
childCount := 0
err = tx.Get(&childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
if childCount > 0 {
return fmt.Errorf("treasure chest has children: %w", ErrBadRequest)
}
transactionsCount := 0
err = tx.Get(&transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
if transactionsCount > 0 {
return fmt.Errorf("treasure chest has transactions: %w", ErrBadRequest)
}
r, err := tx.Exec(`DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
err = db.TransformAndLogDbError("treasureChest Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
return nil
}
func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest {
var (
roots []*types.TreasureChest
result []*types.TreasureChest
)
children := make(map[uuid.UUID][]*types.TreasureChest)
for _, node := range nodes {
if node.ParentId == uuid.Nil {
roots = append(roots, node)
} else {
children[node.ParentId] = append(children[node.ParentId], node)
}
}
slices.SortFunc(roots, func(a, b *types.TreasureChest) int {
return compareStrings(a.Name, b.Name)
})
for _, root := range roots {
result = append(result, root)
childList := children[root.Id]
slices.SortFunc(childList, func(a, b *types.TreasureChest) int {
return compareStrings(a.Name, b.Name)
})
result = append(result, childList...)
}
return result
}
func compareStrings(a, b string) int {
if a == b {
return 0
}
if a < b {
return -1
}
return 1
}

View File

@@ -1,53 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="28.384802mm"
height="31.749905mm"
viewBox="0 0 28.384802 31.749905"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-37.253301,-88.598061)">
<path
d="m 59.240389,97.978247 c 1.775354,-0.394229 4.087813,-2.156354 4.439709,-3.024187 0.206375,-0.508 -0.822855,-1.30175 -1.098021,-1.621896 -0.629709,-0.73025 -0.375709,-1.090083 -0.132292,-1.960562 0.277813,-0.989542 -0.381,-2.082271 -1.314979,-2.510896 -0.933979,-0.428625 -2.050521,-0.293688 -2.989792,0.124354 -0.939271,0.418042 -1.740958,1.090083 -2.526771,1.751542 -0.574145,-0.36248 -1.489604,-1.963209 -2.97127,-0.923396 -1.023938,0.717021 -1.116542,2.278062 -0.98425,3.52425 0.309562,2.876021 1.018645,4.368271 2.354791,4.770437 1.688042,0.508 3.556,0.240771 5.222875,-0.129646"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3" />
<path
d="m 62.124348,89.704727 c -0.224896,3.876145 -4.005792,6.447895 -5.799667,7.580312 l 1.164167,1.000125 c 0,0 0.738187,0.01588 1.748895,-0.306917 1.733021,-0.550333 4.265084,-2.106083 4.439709,-3.024187 0.256646,-1.336146 -1.113896,-1.045104 -1.423459,-2.100792 -0.161395,-0.558271 0.785813,-1.613958 -0.129645,-3.148541 m -6.503459,1.03452 c 0,0 -0.674687,-0.690562 -1.17475,-1.005416 -0.248708,0.468312 -0.425979,0.976312 -0.513291,1.500187 -0.156105,0.92075 0,2.227792 0.36777,3.201459 0.05821,0.150812 0.275167,0.127 0.29898,-0.03175 0.3175,-2.092855 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6" />
<path
d="m 50.906014,97.636935 c 0,0 -8.252354,0.891646 -11.975042,8.056565 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490104,-1.30175 6.863292,-4.0005 6.606646,-6.60664 -0.373062,-3.80471 -3.907896,-6.14363 -3.907896,-6.14363 0,0 0.140229,-4.699 -3.505729,-7.749647 -3.235854,-2.709333 -7.289271,-1.486958 -7.289271,-1.486958"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2" />
<path
d="m 60.042077,103.11381 c 0.280458,-0.19314 1.222375,0.14023 1.486958,1.98438 0.129646,0.90223 0.169333,1.77535 0.169333,1.77535 0,0 -1.11125,-0.99748 -1.47902,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="82.355011"
y="90.66716"
id="text4-9"
transform="rotate(20.578693)"><tspan
id="tspan4-2"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="82.355011"
y="90.66716">$</tspan></text>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M93.46 39.45c6.71-1.49 15.45-8.15 16.78-11.43.78-1.92-3.11-4.92-4.15-6.13-2.38-2.76-1.42-4.12-.5-7.41 1.05-3.74-1.44-7.87-4.97-9.49s-7.75-1.11-11.3.47-6.58 4.12-9.55 6.62c-2.17-1.37-5.63-7.42-11.23-3.49-3.87 2.71-4.22 8.61-3.72 13.32 1.17 10.87 3.85 16.51 8.9 18.03 6.38 1.92 13.44.91 19.74-.49" style="fill:#ffca28"/><path d="M104.36 8.18c-.85 14.65-15.14 24.37-21.92 28.65l4.4 3.78s2.79.06 6.61-1.16c6.55-2.08 16.12-7.96 16.78-11.43.97-5.05-4.21-3.95-5.38-7.94-.61-2.11 2.97-6.1-.49-11.9M79.78 12.09s-2.55-2.61-4.44-3.8c-.94 1.77-1.61 3.69-1.94 5.67-.59 3.48 0 8.42 1.39 12.1.22.57 1.04.48 1.13-.12 1.2-7.91 3.86-13.85 3.86-13.85" style="fill:#e2a610"/><path d="M61.96 38.16S30.77 41.53 16.7 68.61s-2.11 43.5 10.55 49.48 44.56 8.09 65.31 3.17 25.94-15.12 24.97-24.97c-1.41-14.38-14.77-23.22-14.77-23.22s.53-17.76-13.25-29.29c-12.23-10.24-27.55-5.62-27.55-5.62" style="fill:#ffca28"/><path d="M74.76 83.73c-6.69-8.44-14.59-9.57-17.12-12.6-1.38-1.65-2.19-3.32-1.88-5.39.33-2.2 2.88-3.72 4.86-4.09 2.31-.44 7.82-.21 12.45 4.2 1.1 1.04.7 2.66.67 4.11-.08 3.11 4.37 6.13 7.97 3.53 3.61-2.61.84-8.42-1.49-11.24-1.76-2.13-8.14-6.82-16.07-7.56-2.23-.21-11.2-1.54-16.38 8.31-1.49 2.83-2.04 9.67 5.76 15.45 1.63 1.21 10.09 5.51 12.44 8.3 4.07 4.83 1.28 9.08-1.9 9.64-8.67 1.52-13.58-3.17-14.49-5.74-.65-1.83.03-3.81-.81-5.53-.86-1.77-2.62-2.47-4.48-1.88-6.1 1.94-4.16 8.61-1.46 12.28 2.89 3.93 6.44 6.3 10.43 7.6 14.89 4.85 22.05-2.81 23.3-8.42.92-4.11.82-7.67-1.8-10.97" style="fill:#6b4b46"/><path d="M71.16 48.99c-12.67 27.06-14.85 61.23-14.85 61.23" style="fill:none;stroke:#6b4b46;stroke-width:5;stroke-miterlimit:10"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6d4c41"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6b4b46"/><path d="M96.49 58.86c1.06-.73 4.62.53 5.62 7.5.49 3.41.64 6.71.64 6.71s-4.2-3.77-5.59-6.42c-1.75-3.35-2.43-6.59-.67-7.79" style="fill:#e2a610"/></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,100 +0,0 @@
// Initialize the echarts instance based on the prepared dom
async function initMainChart() {
const element = document.getElementById('main-chart')
if (element === null) {
return;
}
var myChart = echarts.init(element);
window.addEventListener('resize', function() {
myChart.resize();
});
try {
const response = await fetch("/dashboard/main-chart");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
option.tooltip.formatter = function (params) {
return new Date(params[0].data[0]).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) +
'<br />' +
'Sum of Accounts: <span class="font-bold">' + params[0].data[1] + '</span> € <br />' +
'Sum of Savings: <span class="font-bold">' + params[1].data[1] + '</span> €'
};
myChart.setOption(option);
console.log("initialized main-chart");
} catch (error) {
console.error(error.message);
}
}
async function initTreasureChests() {
const element = document.getElementById('treasure-chests')
if (element === null) {
return;
}
var myChart = echarts.init(element);
window.addEventListener('resize', function() {
myChart.resize();
});
try {
const response = await fetch("/dashboard/treasure-chests");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
myChart.setOption(option);
console.log("initialized treasure-chests");
} catch (error) {
console.error(error.message);
}
}
async function initTreasureChest() {
const element = document.getElementById('treasure-chest')
if (element === null) {
return;
}
var myChart = echarts.init(element);
window.addEventListener('resize', function() {
myChart.resize();
});
const treasureChestSelect = document.getElementById('treasure-chest-id')
treasureChestSelect.addEventListener("change", async (e) => {
try {
const response = await fetch("/dashboard/treasure-chest?id="+e.target.value);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
option.tooltip.formatter = function (params) {
return new Date(params[0].data[0]).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) +
'<br />' +
'Sum of Accounts: <span class="font-bold">' + params[0].data[1] + '</span> €'
};
myChart.setOption(option);
} catch (error) {
console.error(error.message);
}
});
console.log("initialized treasure-chest");
}
initMainChart();
initTreasureChests();
initTreasureChest();

View File

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

View File

@@ -1,13 +1,14 @@
htmx.on("htmx:afterSwap", () => {
updateTime();
htmx.on("htmx:afterSwap", (e) => {
updateTime(e.target);
});
document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded");
updateTime(document);
})
function updateTime() {
function updateTime(e) {
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;
@@ -16,7 +17,6 @@ function updateTime() {
const newDate = value.includes("UTC") ? new Date(value) : value;
el.valueAsDate = newDate;
}
el.classList.remove("datetime");
})
}

View File

@@ -1,43 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
if (!page || !page1 || !pagePrev1 || !pageNext1 || !page2 || !pagePrev2 || !pageNext2 || !transactionFilterForm) {
return;
}
const scrollToTop = function() {
window.scrollTo(0, 0);
};
const incPage = function() {
const currPage = Number(page.value);
var nextPage = currPage
if (currPage > 1) {
nextPage -= 1;
page.value = nextPage;
transactionFilterForm.dispatchEvent(new Event('change'));
}
page1.textContent = nextPage;
page2.textContent = nextPage;
scrollToTop();
};
const decPage = function() {
const currPage = Number(page.value);
var nextPage = currPage + 1;
page.value = nextPage;
transactionFilterForm.dispatchEvent(new Event('change'));
page1.textContent = nextPage;
page2.textContent = nextPage;
scrollToTop();
};
pagePrev1.addEventListener("click", incPage);
pagePrev2.addEventListener("click", incPage);
pageNext1.addEventListener("click", decPage);
pageNext2.addEventListener("click", decPage);
console.log("initialized pagination");
})

View File

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="96.820343mm"
height="31.749899mm"
viewBox="0 0 96.820343 31.749899"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g7"
transform="translate(-38.090175,-77.467441)">
<g
id="g8"
transform="translate(-38.797122,-28.178962)">
<path
d="m 98.874384,115.02659 c 1.775356,-0.39423 4.087816,-2.15635 4.439706,-3.02419 0.20638,-0.508 -0.82285,-1.30175 -1.09802,-1.62189 -0.62971,-0.73025 -0.37571,-1.09009 -0.13229,-1.96057 0.27781,-0.98954 -0.381,-2.08227 -1.31498,-2.51089 -0.933978,-0.42863 -2.05052,-0.29369 -2.989791,0.12435 -0.939271,0.41804 -1.740958,1.09009 -2.526771,1.75154 -0.574145,-0.36248 -1.489604,-1.96321 -2.97127,-0.92339 -1.023938,0.71702 -1.116542,2.27806 -0.98425,3.52425 0.309562,2.87602 1.018645,4.36827 2.354791,4.77043 1.688042,0.508 3.556,0.24078 5.222875,-0.12964"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3-3" />
<path
d="m 101.75834,106.75307 c -0.22489,3.87614 -4.005789,6.44789 -5.799664,7.58031 l 1.164167,1.00013 c 0,0 0.738187,0.0159 1.748895,-0.30692 1.733022,-0.55033 4.265082,-2.10608 4.439712,-3.02419 0.25664,-1.33614 -1.1139,-1.0451 -1.42346,-2.10079 -0.1614,-0.55827 0.78581,-1.61396 -0.12965,-3.14854 m -6.503456,1.03452 c 0,0 -0.674687,-0.69056 -1.17475,-1.00542 -0.248708,0.46832 -0.425979,0.97632 -0.513291,1.50019 -0.156105,0.92075 0,2.22779 0.36777,3.20146 0.05821,0.15081 0.275167,0.127 0.29898,-0.0317 0.3175,-2.09286 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6-6" />
<path
d="m 90.540009,114.68528 c 0,0 -8.252354,0.89164 -11.975042,8.05656 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490101,-1.30175 6.863291,-4.0005 6.606641,-6.60664 -0.37306,-3.80471 -3.90789,-6.14363 -3.90789,-6.14363 0,0 0.14023,-4.699 -3.50573,-7.74964 -3.235854,-2.70934 -7.289271,-1.48696 -7.289271,-1.48696"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0-1" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6-2" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2-9" />
<path
d="m 99.676072,120.16215 c 0.280458,-0.19314 1.222378,0.14023 1.486958,1.98438 0.12965,0.90223 0.16933,1.77535 0.16933,1.77535 0,0 -1.11125,-0.99748 -1.479017,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6-3" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="125.45235"
y="92.696564"
id="text4-9-1"
transform="rotate(20.578693)"><tspan
id="tspan4-2-9"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="125.45235"
y="92.696564">$</tspan></text>
</g>
<g
id="layer2"
transform="translate(-1.4293676,48.496402)">
<text
xml:space="preserve"
style="font-size:17.6389px;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="57.635151"
y="55.655094"
id="text1"><tspan
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:17.6389px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:-0.529167px;fill:#4d4d4d;stroke:none;stroke-width:0.264583"
x="57.635151"
y="55.655094">pendSparrow</tspan></text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,7 +1,8 @@
package account
import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types"
import "fmt"
import "spend-sparrow/template/svg"
import "spend-sparrow/types"
templ Account(accounts []*types.Account) {
<div class="max-w-6xl mt-10 mx-auto">
@@ -66,9 +67,7 @@ templ EditAccount(account *types.Account) {
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
@@ -82,20 +81,10 @@ templ AccountItem(account *types.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">{ displayBalance(account.CurrentBalance) }</p>
} else {
<p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p>
<p class="mr-20 text-green-700">{ displayBalance(account.CurrentBalance) }</p>
}
<a
href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
class="button button-neglect px-1 flex items-center gap-2"
title="View transactions"
>
@svg.Eye()
<span>
View
</span>
</a>
<button
hx-get={ "/account/" + account.Id.String() + "?edit=true" }
hx-target="closest #account"
@@ -112,7 +101,6 @@ templ AccountItem(account *types.Account) {
hx-target="closest #account"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
hx-confirm="Are you sure you want to delete this account?"
>
@svg.Delete()
<span>
@@ -122,3 +110,9 @@ templ AccountItem(account *types.Account) {
</div>
</div>
}
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f €", euros)
}

View File

@@ -14,7 +14,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
Change Password
</h2>
if !isPasswordReset {
<label class="input flex items-center gap-2">
<label class="input input-bordered flex items-center gap-2">
<input
type="password"
class="grow"
@@ -27,7 +27,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
/>
</label>
}
<label class="input flex items-center gap-2">
<label class="input input-bordered flex items-center gap-2">
<input
type="password"
class="grow"
@@ -39,7 +39,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
autocapitalize="off"
/>
</label>
<button class="button button-primary px-2 self-end">
<button class="btn btn-primary self-end">
Change Password
</button>
</form>

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