Compare commits
306 Commits
bc9bcb2a18
...
fix/lint-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
d3ce7d5ac3
|
|||
| 6e5b4a7b3d | |||
| cc76a77b31 | |||
| 2ac272582f | |||
| 10240977ca | |||
| a7258f6c91 | |||
| 63701f44f5 | |||
| ad1811e37c | |||
| 689dba2f1a | |||
| 7b5fb9e35a | |||
| 237d26675d | |||
| 7ec60b0f93 | |||
| fcb76ae7a8 | |||
| 37525ac31f | |||
| 6b11355857 | |||
| 7d8f6fd1e5 | |||
| 36af297210 | |||
| b05835dde9 | |||
| ff8bd828ec | |||
| 4ec8959db8 | |||
| 1d2a6d1c3a | |||
| 67996068d1 | |||
| 3e8723e359 | |||
| 6c49f0311f | |||
| adf68a8e11 | |||
| a2a88381cb | |||
| e2ddb0b07b | |||
| 1905e5cc03 | |||
| a5e334f4ac | |||
| dc1f9e7a19 | |||
| cd248472f4 | |||
| 76311c3603 | |||
| bba3b32bea | |||
| 73cd04015c | |||
| a9c4304ef8 | |||
| 953d53e884 | |||
| 65b6223256 | |||
| 65fca6390f | |||
| 0394a04c3f | |||
| 29dfd4fa75 | |||
| 12a0ef8c92 | |||
|
65d70fd6df
|
|||
| 278630f2e9 | |||
| bca9563525 | |||
| 8e747efe5f | |||
| 37e5348d7e | |||
| 8e2b4f17aa | |||
| d9be074ef3 | |||
| 037ae74272 | |||
| fd42aa1160 | |||
| b00c93262c | |||
| 68431436fc | |||
| e59541a524 | |||
| 101069b2a6 | |||
| 3759fd8d71 | |||
| aa5636e361 | |||
| ddaf7b8368 | |||
| 49e9b31a2d | |||
| 5944208ca2 | |||
| 95767a8127 | |||
| e16aec5f98 | |||
| 08dcc486d3 | |||
| 0e130aeee4 | |||
| 01101fc2dd | |||
| caedc4ce90 | |||
| 8a3615b612 | |||
| efb1475f11 | |||
| b163495059 | |||
| 24ede772c9 | |||
| 3aca37839c | |||
| 73449d495e | |||
| 7652f823c8 | |||
| 63c2594cbe | |||
| 52693e2846 | |||
| d0faee2950 | |||
|
01d459e913
|
|||
| cee533694c | |||
| f6283c6ab3 | |||
| e48d11b818 | |||
| b9150334ee | |||
| d20981beaa | |||
| 3c95abe59c | |||
| fad2bd3928 | |||
| c75b99ea9d | |||
| 56737a4156 | |||
| ab425d759c | |||
| 283679fc4f | |||
| e0802cf232 | |||
| 6577dbb297 | |||
| 7e37c24b07 | |||
| 3f3edbb8ad | |||
| 16429f1950 | |||
| 82a9fd8220 | |||
| 192e6b7f50 | |||
| 57377f9c27 | |||
| 66227c5818 | |||
| 6e51e3c8b3 | |||
| 6c916aecb4 | |||
| 8575fbf56e | |||
| f820fcdfeb | |||
| f6e58b7afc | |||
| 93e669b038 | |||
| d037317aab | |||
|
0517e7ec89
|
|||
| 867c0ca1cd | |||
| ddcbfaa075 | |||
| 4583c0a70e | |||
|
380854272a
|
|||
|
6bc9e0666b
|
|||
| 9fa554c60a | |||
| 763c952cbe | |||
| fce669146f | |||
| 06219d1fd3 | |||
| 9fac68d7ae | |||
| 8afd48b981 | |||
|
25568591fd
|
|||
| e1551c1fa3 | |||
| e8b3d3e16c | |||
| 59288d4544 | |||
| a2d1f22d46 | |||
| 0e150b3d7d | |||
| 19567313bd | |||
| 42f1cfc07f | |||
| 0276bc6a4c | |||
| d6c8559d4c | |||
| 38cdd96b6f | |||
| cb49494e60 | |||
| 3ffe7514e2 | |||
| d13a387303 | |||
| a398d275f5 | |||
| 23b97a9cac | |||
| 4b74a9b6d4 | |||
| c67f232e9b | |||
| 93727ee49a | |||
| 1a79df9423 | |||
| 582d265fd5 | |||
| f094767582 | |||
| 440fed9ed1 | |||
|
6e1d24eef7
|
|||
| f37b50515b | |||
| b2a512d186 | |||
| 4a28fb5ca4 | |||
| e5f98c1fb0 | |||
| 3072df6507 | |||
| 9f35ca7476 | |||
| 472ab68986 | |||
|
2fd2200ac2
|
|||
| a58ddb7a1d | |||
|
147d57f6e5
|
|||
|
d064626197
|
|||
|
72869e5c68
|
|||
|
3120c19669
|
|||
|
c9bf320611
|
|||
| 3b3343bdb5 | |||
| 6c92206b3c | |||
| ff3c7bdf52 | |||
| 06a8c80f1d | |||
| 596cc602d0 | |||
| 3df9fab25b | |||
| 6b8059889d | |||
| 935019c1c4 | |||
| a9d8e10592 | |||
| e8a13dc8e7 | |||
| 96b4ac414e | |||
| 95340547e6 | |||
| 58547099bc | |||
| 67259d5110 | |||
| 67d10b2b95 | |||
| 910d8848d8 | |||
| 0fd18fbb4f | |||
| 9843db9402 | |||
| baf44d680b | |||
| 0a6cc5c771 | |||
| fa82ce34dc | |||
| c4719db21f | |||
| 2e1a0eedd0 | |||
| 11f3bcc89f | |||
| c4aca2778f | |||
| 63ade5916e | |||
| e65146c71c | |||
| 79a1247bea | |||
| 3e7251ef9d | |||
| b336b65532 | |||
| 7efaa0fc61 | |||
| 95c5b783a7 | |||
| 4cfa904ae1 | |||
| bb4c16c692 | |||
| 3819b4dbd3 | |||
| 889672fefd | |||
| aed1102ad8 | |||
| a506652e05 | |||
| 5775ee7a16 | |||
| 4fa605bd8f | |||
| c2b96145f3 | |||
| 6219741634 | |||
| 9bb0cc475d | |||
| 76da3ca703 | |||
| 1e7f2878ba | |||
| be7209a4c6 | |||
| e67ac99c7f | |||
| 3efd3b7baf | |||
| 128a2fc4d7 | |||
| 2ba5ddd9f2 | |||
| b7d216a982 | |||
| 1681a4fcf9 | |||
| 5ee14578f8 | |||
| ff6e348675 | |||
| 78b7905813 | |||
| e92c2f991f | |||
| f74f62cefc | |||
| 0dcf7daf7f | |||
| b27a2050a7 | |||
| 057c3cdb1b | |||
| 3c6fd72d85 | |||
| 7a691ec263 | |||
| b18863038c | |||
| 8b67cfccfa | |||
| 2e9d1e4a8d | |||
| e8a1c55424 | |||
| 25e748c12b | |||
| b3b840982c | |||
| 36e480f2ea | |||
| 989a31afd1 | |||
| 84b7144f7b | |||
| 311c5aed0c | |||
| 70426361ad | |||
| 5ef27d9b52 | |||
| 7d51de7adf | |||
| 402a90f8f4 | |||
| 6a254c09cf | |||
| 7d71f5a519 | |||
| 3dc9f8ec6f | |||
| af9b785985 | |||
| 1e78b40c3b | |||
| c1a66bb261 | |||
| 7e244ccc07 | |||
| dbf272e3f3 | |||
| c9ea9bd935 | |||
| ae42d6d1e6 | |||
| 79f4394e2c | |||
| fd19fc65ff | |||
| 9fce0c72bf | |||
| 96ca636fbb | |||
| df022c9077 | |||
| 0792d8e01a | |||
| 0203504f99 | |||
| 5cfea4e2d3 | |||
| 4744da0bee | |||
| 511c4ca22b | |||
| 8f392fb0a8 | |||
| a58e8c6694 | |||
| b35d638070 | |||
| 3e280dcd7f | |||
| b8f13dfc93 | |||
| 605c64ef92 | |||
| f085ed378e | |||
| 81380f184e | |||
| ac0c918da7 | |||
| 19e79cbe3f | |||
| f24f1bb82e | |||
| 3a8c814f2f | |||
| 7bda5237e3 | |||
| b20a48be25 | |||
| af89aa8639 | |||
| 434b44be28 | |||
| 96860b28d1 | |||
| 3d0b12e5a9 | |||
| b50067d3c7 | |||
| c7438c115e | |||
| be5e3e7053 | |||
| 9ccb151b2c | |||
| e04a10224e | |||
| a8ac3ec9ea | |||
| 8479b77ea9 | |||
| 5f355d3f73 | |||
| b9031a7fb5 | |||
| 1006d91dac | |||
| 998712a1a6 | |||
| 923247b55e | |||
| 8c20b95628 | |||
| c246254062 | |||
| dc263bfbe1 | |||
| de2daf5f26 | |||
| 073152a1e2 | |||
| 20df00f4cf | |||
| a8d6da9a86 | |||
| b58f8c041c | |||
| 8302cfa629 | |||
| 552849f5c5 | |||
| 928e34eb83 | |||
| e402fa078a | |||
| 200b3cba20 | |||
| c0c32211bc | |||
| 6ede20aebb | |||
| 251dfc0ec5 | |||
| ef97c1b5db | |||
| b772a6d83b | |||
| 027f5bb393 | |||
| 84ac67c6ad | |||
| d14e4c2405 | |||
| 25279f2251 | |||
| a2049ef5ad | |||
| 610ad1b562 | |||
| ee32cacc14 | |||
| 253a44a11f | |||
| 84843c51d3 |
15
.air.toml
15
.air.toml
@@ -1,15 +0,0 @@
|
|||||||
root = "."
|
|
||||||
tmp_dir = "tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
bin = "./tmp/main"
|
|
||||||
cmd = "templ generate && go build -o ./tmp/main ."
|
|
||||||
exclude_dir = ["static", "migrations", "node_modules", "tmp"]
|
|
||||||
exclude_regex = ["_test.go", "_templ.go"]
|
|
||||||
include_ext = ["go", "templ"]
|
|
||||||
send_interrupt = true
|
|
||||||
kill_delay = "5s"
|
|
||||||
stop_on_error = true
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
clean_on_exit = true
|
|
||||||
@@ -10,6 +10,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
- run: docker build . -t spend-sparrow-test
|
- run: docker build . -t spend-sparrow-test
|
||||||
- run: docker rmi spend-sparrow-test
|
- run: docker rmi spend-sparrow-test
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
||||||
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
|
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
|
||||||
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest
|
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,12 +25,13 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
*.db
|
data/
|
||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
static/css/tailwind.css
|
static/css/tailwind.css
|
||||||
static/js/htmx.min.js
|
static/js/htmx.min.js
|
||||||
|
static/js/echarts.min.js
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
mocks/*
|
mocks/*
|
||||||
|
|||||||
32
.golangci.yaml
Normal file
32
.golangci.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
@@ -3,11 +3,11 @@ dir: mocks/
|
|||||||
outpkg: mocks
|
outpkg: mocks
|
||||||
issue-845-fix: True
|
issue-845-fix: True
|
||||||
packages:
|
packages:
|
||||||
spend-sparrow/service:
|
spend-sparrow/internal/service:
|
||||||
interfaces:
|
interfaces:
|
||||||
Random:
|
Random:
|
||||||
Clock:
|
Clock:
|
||||||
Mail:
|
Mail:
|
||||||
spend-sparrow/db:
|
spend-sparrow/internal/db:
|
||||||
interfaces:
|
interfaces:
|
||||||
Auth:
|
Auth:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM golang:1.24.2@sha256:991aa6a6e4431f2f01e869a812934bd60fbc87fb939e4a1ea54b8494ab9d2fc6 AS builder_go
|
FROM golang:1.25.3@sha256:7e3cbcd2f6af1bebb937462ec29f77ce28b406081af509afed158fa8721f11af AS builder_go
|
||||||
WORKDIR /spend-sparrow
|
WORKDIR /spend-sparrow
|
||||||
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
RUN go install github.com/vektra/mockery/v2@latest
|
RUN go install github.com/vektra/mockery/v2@latest
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -13,7 +13,7 @@ RUN golangci-lint run ./...
|
|||||||
RUN go build -o /spend-sparrow/spend-sparrow .
|
RUN go build -o /spend-sparrow/spend-sparrow .
|
||||||
|
|
||||||
|
|
||||||
FROM node:22.14.0@sha256:c7fd844945a76eeaa83cb372e4d289b4a30b478a1c80e16c685b62c54156285b AS builder_node
|
FROM node:24.11.0@sha256:e5bbac0e9b8a6e3b96a86a82bbbcf4c533a879694fd613ed616bae5116f6f243 AS builder_node
|
||||||
WORKDIR /spend-sparrow
|
WORKDIR /spend-sparrow
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm clean-install
|
RUN npm clean-install
|
||||||
@@ -21,7 +21,7 @@ COPY . ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM debian:12.10@sha256:18023f131f52fc3ea21973cabffe0b216c60b417fd2478e94d9d59981ebba6af
|
FROM debian:13.1@sha256:01a723bf5bfb21b9dda0c9a33e0538106e4d02cce8f557e118dd61259553d598
|
||||||
WORKDIR /spend-sparrow
|
WORKDIR /spend-sparrow
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
||||||
COPY migration ./migration
|
COPY migration ./migration
|
||||||
|
|||||||
100
Readme.md
100
Readme.md
@@ -1,98 +1,22 @@
|
|||||||
|
|
||||||
# Web-App-Template
|
# SpendSparrow
|
||||||
|
|
||||||
A basic template with authentication to easily host on a VPC.
|
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.
|
||||||
|
|
||||||
## Features
|
## Prerequisites
|
||||||
|
|
||||||
This template includes everything essential to build an app. It includes the following features:
|
|
||||||
|
|
||||||
- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.)
|
|
||||||
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone.
|
|
||||||
- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included.
|
|
||||||
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains.
|
|
||||||
- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite
|
|
||||||
|
|
||||||
|
|
||||||
## Architecture Design Decisions
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
### Authentication
|
## Design priciples
|
||||||
|
|
||||||
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision.
|
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.
|
||||||
|
It may be applicable to do some sort of monthly snapshots to speed up calculations, but this will be only done if database queries become a bottleneck.
|
||||||
|
|
||||||
There are a few restrictions I would like to contain:
|
This applications uses as little dependencies as feasible, especially on the front end.
|
||||||
- I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
|
|
||||||
- It should still be possible to run on a small VPC (2vcpu, 2GB).
|
|
||||||
- It should be as secure as possible
|
|
||||||
|
|
||||||
I determined 4 options:
|
|
||||||
1. Implement the authentication myself
|
|
||||||
2. Using OAuth2 with Keycloak
|
|
||||||
3. Using OAuth2 with Google and Apple
|
|
||||||
4. Firebase, Clerk, etc.
|
|
||||||
|
|
||||||
|
|
||||||
#### 1. Implement the authentication myself
|
|
||||||
|
|
||||||
It's always possible to implement it myself. The topic of authentication is something special though.
|
|
||||||
|
|
||||||
Pros:
|
|
||||||
- Great Cheat cheets from OWASP
|
|
||||||
- No adittional configuration or services needed
|
|
||||||
- Great learning experience on the topic "security"
|
|
||||||
Cons:
|
|
||||||
- Great attack vector
|
|
||||||
- Introcution of vlunerabillities is possible
|
|
||||||
- No DDOS protection
|
|
||||||
|
|
||||||
#### 2. Using OAuth2 with Google and Apple
|
|
||||||
|
|
||||||
Instead of implementing authentication from scratch, an external OAuth2 provider is embedded into the application.
|
|
||||||
|
|
||||||
Pros:
|
|
||||||
- The Systems of BigTech are probably safer. They have security experts employed.
|
|
||||||
- The other external system is responsible to prevent credential stuffing attacks, etc.
|
|
||||||
- Users don't have to create new credentials
|
|
||||||
Cons:
|
|
||||||
- High dependency on those providers
|
|
||||||
- Single Point of failure (If your account is banned, your application access get's lost as well.)
|
|
||||||
- It's possible that these providers ban the whole application (All users lose access)
|
|
||||||
- There still needs to be implemented some logic
|
|
||||||
- Full application integration can be difficult
|
|
||||||
|
|
||||||
#### 3. Using OAuth2 with Keycloak
|
|
||||||
|
|
||||||
This option is almost identical with the previois one, but the provider is self hosted.
|
|
||||||
|
|
||||||
Pros:
|
|
||||||
- Indipendent from 3rd party providers
|
|
||||||
- The credentials are stored safly
|
|
||||||
Cons:
|
|
||||||
- Self hosted (no DDOS protection, etc.)
|
|
||||||
- There still needs to be implemented some logic server side
|
|
||||||
- Full application integration can be difficult
|
|
||||||
|
|
||||||
|
|
||||||
#### 4. Firebase, Clerk, etc.
|
|
||||||
|
|
||||||
Users can sign in with a seperate sdk on your website
|
|
||||||
|
|
||||||
Pros:
|
|
||||||
- Safe and Sound authentication
|
|
||||||
Cons:
|
|
||||||
- Dependent on those providers / adittional setup needed
|
|
||||||
- Application can be banned
|
|
||||||
- Still some integration code needed
|
|
||||||
|
|
||||||
#### Decision
|
|
||||||
|
|
||||||
I've decided on implementing authentication myself, as this is a great learning opportunity. It may not be as secure as other solutions, but if I keep tighly to the OWASP recommendations, it should should good enough.
|
|
||||||
|
|
||||||
|
|
||||||
### Email
|
|
||||||
|
|
||||||
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails.
|
|
||||||
|
|
||||||
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails.
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
188
assest-source/logo-inkscape.svg
Normal file
188
assest-source/logo-inkscape.svg
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
408
db/auth.go
408
db/auth.go
@@ -1,408 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"spend-sparrow/log"
|
|
||||||
"spend-sparrow/types"
|
|
||||||
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNotFound = errors.New("value not found")
|
|
||||||
ErrAlreadyExists = errors.New("row already exists")
|
|
||||||
)
|
|
||||||
|
|
||||||
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 *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuthSqlite(db *sql.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 workout WHERE user_id = ?", userId)
|
|
||||||
if err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
log.Error("Could not delete workouts: %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.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 not found: %v", 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
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"spend-sparrow/log"
|
|
||||||
"spend-sparrow/types"
|
|
||||||
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RunMigrations(db *sql.DB, pathPrefix string) error {
|
|
||||||
driver, err := sqlite3.WithInstance(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
|
|
||||||
}
|
|
||||||
|
|
||||||
119
db/workout.go
119
db/workout.go
@@ -1,119 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"spend-sparrow/log"
|
|
||||||
"spend-sparrow/types"
|
|
||||||
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrWorkoutNotExists = errors.New("Workout does not exist")
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkoutDb interface {
|
|
||||||
InsertWorkout(userId uuid.UUID, workout *WorkoutInsert) (*Workout, error)
|
|
||||||
GetWorkouts(userId uuid.UUID) ([]Workout, error)
|
|
||||||
DeleteWorkout(userId uuid.UUID, rowId int) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkoutDbSqlite struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWorkoutDbSqlite(db *sql.DB) *WorkoutDbSqlite {
|
|
||||||
return &WorkoutDbSqlite{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkoutInsert struct {
|
|
||||||
Date time.Time
|
|
||||||
Type string
|
|
||||||
Sets int
|
|
||||||
Reps int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Workout struct {
|
|
||||||
RowId int
|
|
||||||
Date time.Time
|
|
||||||
Type string
|
|
||||||
Sets int
|
|
||||||
Reps int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWorkoutInsert(date time.Time, workoutType string, sets int, reps int) *WorkoutInsert {
|
|
||||||
return &WorkoutInsert{Date: date, Type: workoutType, Sets: sets, Reps: reps}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWorkoutFromInsert(rowId int, workoutInsert *WorkoutInsert) *Workout {
|
|
||||||
return &Workout{RowId: rowId, Date: workoutInsert.Date, Type: workoutInsert.Type, Sets: workoutInsert.Sets, Reps: workoutInsert.Reps}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db WorkoutDbSqlite) InsertWorkout(userId uuid.UUID, workout *WorkoutInsert) (*Workout, error) {
|
|
||||||
var rowId int
|
|
||||||
err := db.db.QueryRow(`
|
|
||||||
INSERT INTO workout (user_id, date, type, sets, reps)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
RETURNING rowid`, userId, workout.Date, workout.Type, workout.Sets, workout.Reps).Scan(&rowId)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error inserting workout: %v", err)
|
|
||||||
return nil, types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewWorkoutFromInsert(rowId, workout), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db WorkoutDbSqlite) GetWorkouts(userId uuid.UUID) ([]Workout, error) {
|
|
||||||
|
|
||||||
rows, err := db.db.Query("SELECT rowid, date, type, sets, reps FROM workout WHERE user_id = ? ORDER BY date desc", userId)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not get workouts: %v", err)
|
|
||||||
return nil, types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
var workouts = make([]Workout, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
workout Workout
|
|
||||||
date string
|
|
||||||
)
|
|
||||||
|
|
||||||
err = rows.Scan(&workout.RowId, &date, &workout.Type, &workout.Sets, &workout.Reps)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not scan workout: %v", err)
|
|
||||||
return nil, types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
workout.Date, err = time.Parse("2006-01-02 15:04:05-07:00", date)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not parse date: %v", err)
|
|
||||||
return nil, types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
workouts = append(workouts, workout)
|
|
||||||
}
|
|
||||||
|
|
||||||
return workouts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db WorkoutDbSqlite) DeleteWorkout(userId uuid.UUID, rowId int) error {
|
|
||||||
|
|
||||||
res, err := db.db.Exec("DELETE FROM workout WHERE user_id = ? AND rowid = ?", userId, rowId)
|
|
||||||
if err != nil {
|
|
||||||
return types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := res.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return types.ErrInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
return ErrWorkoutNotExists
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
12
dev.sh
Executable file
12
dev.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
read -n1 -s
|
||||||
|
kill $(jobs -p)
|
||||||
|
|
||||||
56
go.mod
56
go.mod
@@ -1,36 +1,54 @@
|
|||||||
module spend-sparrow
|
module spend-sparrow
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.2
|
toolchain go1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.857
|
github.com/a-h/templ v0.3.960
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.25
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/prometheus/client_golang v1.21.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
|
||||||
golang.org/x/crypto v0.36.0
|
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
|
||||||
golang.org/x/net v0.38.0
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.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/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.1 // 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
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
128
go.sum
128
go.sum
@@ -1,64 +1,112 @@
|
|||||||
github.com/a-h/templ v0.3.857 h1:6EqcJuGZW4OL+2iZ3MD+NnIcG7nGkaQeF2Zq5kf9ZGg=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
github.com/a-h/templ v0.3.857/go.mod h1:qhrhAkRFubE7khxLZHsBFHfX+gWwVNKbzKeF9GlPV4M=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.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.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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-sqlite3 v1.14.25 h1:rszkIulEvxqZ8JfFG4yWEZh5u9qAKeSOdea67p8kk6s=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.25/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
||||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
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=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"spend-sparrow/handler/middleware"
|
|
||||||
"spend-sparrow/service"
|
|
||||||
"spend-sparrow/template"
|
|
||||||
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Index interface {
|
|
||||||
Handle(router *http.ServeMux)
|
|
||||||
}
|
|
||||||
|
|
||||||
type IndexImpl struct {
|
|
||||||
service service.Auth
|
|
||||||
render *Render
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewIndex(service service.Auth, render *Render) Index {
|
|
||||||
return IndexImpl{
|
|
||||||
service: service,
|
|
||||||
render: render,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler IndexImpl) Handle(router *http.ServeMux) {
|
|
||||||
router.Handle("/", handler.handleIndexAnd404())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler IndexImpl) handleIndexAnd404() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := middleware.GetUser(r)
|
|
||||||
|
|
||||||
var comp templ.Component
|
|
||||||
|
|
||||||
var status int
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
comp = template.NotFound()
|
|
||||||
status = http.StatusNotFound
|
|
||||||
} else {
|
|
||||||
comp = template.Index()
|
|
||||||
status = http.StatusOK
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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 (rr *csrfResponseWriter) WriteHeader(statusCode int) {
|
|
||||||
rr.ResponseWriter.WriteHeader(statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 not correct")
|
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
|
||||||
utils.TriggerToast(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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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.ResponseWriter.WriteHeader(code)
|
|
||||||
w.StatusCode = 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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"spend-sparrow/handler/middleware"
|
|
||||||
"spend-sparrow/service"
|
|
||||||
"spend-sparrow/template/workout"
|
|
||||||
"spend-sparrow/utils"
|
|
||||||
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Workout interface {
|
|
||||||
Handle(router *http.ServeMux)
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkoutImpl struct {
|
|
||||||
service service.Workout
|
|
||||||
auth service.Auth
|
|
||||||
render *Render
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWorkout(service service.Workout, auth service.Auth, render *Render) Workout {
|
|
||||||
return WorkoutImpl{
|
|
||||||
service: service,
|
|
||||||
auth: auth,
|
|
||||||
render: render,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler WorkoutImpl) Handle(router *http.ServeMux) {
|
|
||||||
router.Handle("/workout", handler.handleWorkoutPage())
|
|
||||||
router.Handle("POST /api/workout", handler.handleAddWorkout())
|
|
||||||
router.Handle("GET /api/workout", handler.handleGetWorkout())
|
|
||||||
router.Handle("DELETE /api/workout/{id}", handler.handleDeleteWorkout())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler WorkoutImpl) handleWorkoutPage() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := middleware.GetUser(r)
|
|
||||||
if user == nil {
|
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDate := time.Now().Format("2006-01-02")
|
|
||||||
comp := workout.WorkoutComp(currentDate)
|
|
||||||
handler.render.RenderLayout(r, w, comp, user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler WorkoutImpl) handleAddWorkout() 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 dateStr = r.FormValue("date")
|
|
||||||
var typeStr = r.FormValue("type")
|
|
||||||
var setsStr = r.FormValue("sets")
|
|
||||||
var repsStr = r.FormValue("reps")
|
|
||||||
|
|
||||||
wo := service.NewWorkoutDto("", dateStr, typeStr, setsStr, repsStr)
|
|
||||||
wo, err := handler.service.AddWorkout(user, wo)
|
|
||||||
if err != nil {
|
|
||||||
utils.TriggerToast(w, r, "error", "Invalid input values", http.StatusBadRequest)
|
|
||||||
http.Error(w, "Invalid input values", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wor := workout.Workout{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps}
|
|
||||||
|
|
||||||
comp := workout.WorkoutItemComp(wor, true)
|
|
||||||
handler.render.Render(r, w, comp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := middleware.GetUser(r)
|
|
||||||
if user == nil {
|
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
workouts, err := handler.service.GetWorkouts(user)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wos := make([]workout.Workout, 0)
|
|
||||||
for _, wo := range workouts {
|
|
||||||
wos = append(wos, workout.Workout{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps})
|
|
||||||
}
|
|
||||||
|
|
||||||
comp := workout.WorkoutListComp(wos)
|
|
||||||
handler.render.Render(r, w, comp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler WorkoutImpl) handleDeleteWorkout() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := middleware.GetUser(r)
|
|
||||||
if user == nil {
|
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rowId := r.PathValue("id")
|
|
||||||
if rowId == "" {
|
|
||||||
utils.TriggerToast(w, r, "error", "Missing ID field", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rowIdInt, err := strconv.Atoi(rowId)
|
|
||||||
if err != nil {
|
|
||||||
utils.TriggerToast(w, r, "error", "Invalid ID", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.service.DeleteWorkout(user, rowIdInt)
|
|
||||||
if err != nil {
|
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
66
input.css
66
input.css
@@ -3,16 +3,60 @@
|
|||||||
@source './static/**/*.js';
|
@source './static/**/*.js';
|
||||||
@source './template/**/*.templ';
|
@source './template/**/*.templ';
|
||||||
|
|
||||||
@theme {
|
@font-face {
|
||||||
--animate-fade: fadeOut 0.25s ease-in;
|
font-family: "EB Garamond";
|
||||||
|
src: url("/static/font/EBGaramond-VariableFont_wght.woff2") format("woff2");
|
||||||
@keyframes fadeOut {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "EB Garamond", serif;
|
||||||
|
@apply text-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
@apply outline-none ring-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.button {
|
||||||
|
transition: all 150ms linear;
|
||||||
|
@apply cursor-pointer border-2 rounded-lg border-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover,
|
||||||
|
.button-normal:hover {
|
||||||
|
transform: translate(-0.25rem, -0.25rem);
|
||||||
|
box-shadow: 3px 3px 3px var(--color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
@apply border-gray-400
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-normal {
|
||||||
|
@apply border-gray-200
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-neglect:hover {
|
||||||
|
@apply border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 2px solid var(--color-gray-200);
|
||||||
|
transition: all 150ms linear;
|
||||||
|
@apply px-3 py-2 text-lg;
|
||||||
|
}
|
||||||
|
.input:has(input:focus), .input:focus {
|
||||||
|
border-color: var(--color-gray-400);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
405
internal/db/auth.go
Normal file
405
internal/db/auth.go
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
39
internal/db/error.go
Normal file
39
internal/db/error.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"spend-sparrow/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("the value does not exist")
|
||||||
|
ErrAlreadyExists = errors.New("row already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if r != nil {
|
||||||
|
rows, err := r.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
slog.InfoContext(ctx, "row not found", "module", module)
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
internal/db/migration.go
Normal file
48
internal/db/migration.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
178
internal/default.go
Normal file
178
internal/default.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
144
internal/handler/account.go
Normal file
144
internal/handler/account.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Account interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountImpl struct {
|
||||||
|
s service.Account
|
||||||
|
r *Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccount(s service.Account, r *Render) Account {
|
||||||
|
return AccountImpl{
|
||||||
|
s: s,
|
||||||
|
r: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h AccountImpl) Handle(r *http.ServeMux) {
|
||||||
|
r.Handle("GET /account", h.handleAccountPage())
|
||||||
|
r.Handle("GET /account/{id}", h.handleAccountItemComp())
|
||||||
|
r.Handle("POST /account/{id}", h.handleUpdateAccount())
|
||||||
|
r.Handle("DELETE /account/{id}", h.handleDeleteAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := t.Account(accounts)
|
||||||
|
h.r.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "new" {
|
||||||
|
comp := t.EditAccount(nil)
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.s.Get(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var comp templ.Component
|
||||||
|
if r.URL.Query().Get("edit") == "true" {
|
||||||
|
comp = t.EditAccount(account)
|
||||||
|
} else {
|
||||||
|
comp = t.AccountItem(account)
|
||||||
|
}
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
account *types.Account
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
id := r.PathValue("id")
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if id == "new" {
|
||||||
|
account, err = h.s.Add(r.Context(), user, name)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
account, err = h.s.UpdateName(r.Context(), user, id, name)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := t.AccountItem(account)
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
|
||||||
|
err := h.s.Delete(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"spend-sparrow/handler/middleware"
|
|
||||||
"spend-sparrow/log"
|
|
||||||
"spend-sparrow/service"
|
|
||||||
"spend-sparrow/template/auth"
|
|
||||||
"spend-sparrow/types"
|
|
||||||
"spend-sparrow/utils"
|
|
||||||
|
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +58,8 @@ var (
|
|||||||
|
|
||||||
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
if !user.EmailVerified {
|
if !user.EmailVerified {
|
||||||
@@ -77,13 +78,14 @@ func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
|
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
|
||||||
session := middleware.GetSession(r)
|
session := middleware.GetSession(r)
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
||||||
session, user, err := handler.service.SignIn(session, email, password)
|
session, user, err := handler.service.SignIn(r.Context(), session, email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -95,10 +97,10 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrInvalidCredentials {
|
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||||
utils.TriggerToast(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,8 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
|
|
||||||
if user != nil {
|
if user != nil {
|
||||||
@@ -131,6 +135,8 @@ func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
@@ -149,27 +155,30 @@ func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
|
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go handler.service.SendVerificationMail(user.Id, user.Email)
|
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
|
||||||
|
|
||||||
_, err := w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
|
_, err := w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Could not write response: %v", err)
|
slog.ErrorContext(r.Context(), "Could not write response", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
|
|
||||||
err := handler.service.VerifyUserEmail(token)
|
err := handler.service.VerifyUserEmail(r.Context(), token)
|
||||||
|
|
||||||
isVerified := err == nil
|
isVerified := err == nil
|
||||||
comp := auth.VerifyResponseComp(isVerified)
|
comp := auth.VerifyResponseComp(isVerified)
|
||||||
@@ -187,45 +196,50 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
|
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
var email = r.FormValue("email")
|
var email = r.FormValue("email")
|
||||||
var password = r.FormValue("password")
|
var password = r.FormValue("password")
|
||||||
|
|
||||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
|
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||||
log.Info("Signing up %v", email)
|
slog.InfoContext(r.Context(), "signing up", "email", email)
|
||||||
user, err := handler.service.SignUp(email, password)
|
user, err := handler.service.SignUp(r.Context(), email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Sending verification email to %v", user.Email)
|
slog.InfoContext(r.Context(), "Sending verification email", "to", user.Email)
|
||||||
go handler.service.SendVerificationMail(user.Id, user.Email)
|
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, types.ErrInternal) {
|
switch {
|
||||||
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
|
case errors.Is(err, types.ErrInternal):
|
||||||
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
} else if errors.Is(err, service.ErrInvalidEmail) {
|
case errors.Is(err, service.ErrInvalidEmail):
|
||||||
utils.TriggerToast(w, r, "error", "The email provided is invalid", http.StatusBadRequest)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
} else if errors.Is(err, service.ErrInvalidPassword) {
|
case errors.Is(err, service.ErrInvalidPassword):
|
||||||
utils.TriggerToast(w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If err is "service.ErrAccountExists", then just continue
|
// If err is "service.ErrAccountExists", then just continue
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.TriggerToast(w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
session := middleware.GetSession(r)
|
session := middleware.GetSession(r)
|
||||||
|
|
||||||
if session != nil {
|
if session != nil {
|
||||||
err := handler.service.SignOut(session.Id)
|
err := handler.service.SignOut(r.Context(), session.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "An error occurred", http.StatusInternalServerError)
|
http.Error(w, "An error occurred", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -249,6 +263,8 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
@@ -262,6 +278,8 @@ func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
@@ -270,12 +288,12 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
|||||||
|
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
||||||
err := handler.service.DeleteAccount(user, password)
|
err := handler.service.DeleteAccount(r.Context(), user, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrInvalidCredentials {
|
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||||
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,6 +304,7 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
isPasswordReset := r.URL.Query().Has("token")
|
isPasswordReset := r.URL.Query().Has("token")
|
||||||
|
|
||||||
@@ -303,29 +322,31 @@ func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
session := middleware.GetSession(r)
|
session := middleware.GetSession(r)
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if session == nil || user == nil {
|
if session == nil || user == nil {
|
||||||
utils.TriggerToast(w, r, "error", "Unathorized", http.StatusUnauthorized)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currPass := r.FormValue("current-password")
|
currPass := r.FormValue("current-password")
|
||||||
newPass := r.FormValue("new-password")
|
newPass := r.FormValue("new-password")
|
||||||
|
|
||||||
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
|
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
|
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
@@ -340,43 +361,46 @@ func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
|
|||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
|
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
if email == "" {
|
if email == "" {
|
||||||
utils.TriggerToast(w, r, "error", "Please enter an email", http.StatusBadRequest)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
|
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||||
err := handler.service.SendForgotPasswordMail(email)
|
err := handler.service.SendForgotPasswordMail(r.Context(), email)
|
||||||
return nil, err
|
return nil, err
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToast(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
updateSpan(r)
|
||||||
|
|
||||||
|
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Could not get current URL: %v", err)
|
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := pageUrl.Query().Get("token")
|
token := pageUrl.Query().Get("token")
|
||||||
newPass := r.FormValue("new-password")
|
newPass := r.FormValue("new-password")
|
||||||
|
|
||||||
err = handler.service.ForgotPassword(token, newPass)
|
err = handler.service.ForgotPassword(r.Context(), token, newPass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToast(w, r, "error", err.Error(), http.StatusBadRequest)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
|
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
254
internal/handler/dashboard.go
Normal file
254
internal/handler/dashboard.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/handler/default.go
Normal file
46
internal/handler/default.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/internal/db"
|
||||||
|
"spend-sparrow/internal/service"
|
||||||
|
"spend-sparrow/internal/utils"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrUnauthorized):
|
||||||
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
case errors.Is(err, service.ErrBadRequest):
|
||||||
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
case errors.Is(err, db.ErrNotFound):
|
||||||
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractErrorMessage(err error) string {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if errMsg == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.SplitN(errMsg, ":", 2)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSpan(r *http.Request) {
|
||||||
|
currentSpan := trace.SpanFromContext(r.Context())
|
||||||
|
if currentSpan != nil {
|
||||||
|
currentSpan.SetAttributes(attribute.String("http.pattern", r.Pattern))
|
||||||
|
currentSpan.SetAttributes(attribute.String("http.pattern.id", r.PathValue("id")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"spend-sparrow/service"
|
"spend-sparrow/internal/service"
|
||||||
"spend-sparrow/types"
|
"spend-sparrow/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextKey string
|
type ContextKey string
|
||||||
@@ -16,15 +17,21 @@ var UserKey ContextKey = "user"
|
|||||||
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if strings.Contains(r.URL.Path, "/static/") {
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sessionId := getSessionID(r)
|
sessionId := getSessionID(r)
|
||||||
session, user, _ := service.SignInSession(sessionId)
|
session, user, _ := service.SignInSession(r.Context(), sessionId)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
// Always sign in anonymous
|
// Always sign in anonymous
|
||||||
// This way, we can always generate csrf tokens
|
// This way, we can always generate csrf tokens
|
||||||
if session == nil {
|
if session == nil {
|
||||||
session, err = service.SignInAnonymous()
|
session, err = service.SignInAnonymous(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -34,7 +41,6 @@ func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
|||||||
http.SetCookie(w, &cookie)
|
http.SetCookie(w, &cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
ctx = context.WithValue(ctx, UserKey, user)
|
ctx = context.WithValue(ctx, UserKey, user)
|
||||||
ctx = context.WithValue(ctx, SessionKey, session)
|
ctx = context.WithValue(ctx, SessionKey, session)
|
||||||
|
|
||||||
@@ -49,7 +55,12 @@ func GetUser(r *http.Request) *types.User {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return obj.(*types.User)
|
user, ok := obj.(*types.User)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSession(r *http.Request) *types.Session {
|
func GetSession(r *http.Request) *types.Session {
|
||||||
@@ -58,7 +69,12 @@ func GetSession(r *http.Request) *types.Session {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return obj.(*types.Session)
|
session, ok := obj.(*types.Session)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSessionID(r *http.Request) string {
|
func getSessionID(r *http.Request) string {
|
||||||
@@ -3,18 +3,18 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CacheControl(next http.Handler) http.Handler {
|
func CacheControl(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
counter, _ := otel.Meter("").Int64Counter("spend.sparrow.test")
|
||||||
|
counter.Add(r.Context(), 1)
|
||||||
|
|
||||||
cached := false
|
shouldCache := strings.HasPrefix(r.URL.Path, "/static")
|
||||||
if strings.HasPrefix(path, "/static") {
|
|
||||||
cached = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cached {
|
if !shouldCache {
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
}
|
}
|
||||||
|
|
||||||
76
internal/handler/middleware/cross_site_request_forgery.go
Normal file
76
internal/handler/middleware/cross_site_request_forgery.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
39
internal/handler/middleware/gzip.go
Normal file
39
internal/handler/middleware/gzip.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gzipResponseWriter struct {
|
||||||
|
io.Writer
|
||||||
|
http.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
return w.Writer.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Gzip(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
wrapper := gzipResponseWriter{Writer: gz, ResponseWriter: w}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
37
internal/handler/middleware/logger.go
Normal file
37
internal/handler/middleware/logger.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,12 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"spend-sparrow/internal/types"
|
||||||
"spend-sparrow/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
|
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
@@ -16,6 +14,7 @@ func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Han
|
|||||||
w.Header().Set("Content-Security-Policy",
|
w.Header().Set("Content-Security-Policy",
|
||||||
"default-src 'none'; "+
|
"default-src 'none'; "+
|
||||||
"script-src 'self'; "+
|
"script-src 'self'; "+
|
||||||
|
"font-src 'self'; "+
|
||||||
"connect-src 'self'; "+
|
"connect-src 'self'; "+
|
||||||
"img-src 'self'; "+
|
"img-src 'self'; "+
|
||||||
"style-src 'self'; "+
|
"style-src 'self'; "+
|
||||||
@@ -29,7 +28,7 @@ func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Han
|
|||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
|
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
|
||||||
|
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,12 @@ package middleware
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
|
// Wrapper wraps a list of handlers together.
|
||||||
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
|
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
lastHandler := next
|
lastHandler := next
|
||||||
for i := len(handlers) - 1; i >= 0; i-- {
|
for _, handler := range handlers {
|
||||||
lastHandler = handlers[i](lastHandler)
|
lastHandler = handler(lastHandler)
|
||||||
}
|
}
|
||||||
lastHandler.ServeHTTP(w, r)
|
lastHandler.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"spend-sparrow/log"
|
"log/slog"
|
||||||
"spend-sparrow/template"
|
|
||||||
"spend-sparrow/template/auth"
|
|
||||||
"spend-sparrow/types"
|
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"spend-sparrow/internal/template"
|
||||||
|
"spend-sparrow/internal/template/auth"
|
||||||
|
"spend-sparrow/internal/types"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
@@ -23,7 +22,7 @@ func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, c
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
err := comp.Render(r.Context(), w)
|
err := comp.Render(r.Context(), w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to render layout: %v", err)
|
slog.ErrorContext(r.Context(), "Failed to render layout", "err", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,13 +37,12 @@ func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot
|
|||||||
|
|
||||||
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
|
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
|
||||||
userComp := render.getUserComp(user)
|
userComp := render.getUserComp(user)
|
||||||
layout := template.Layout(slot, userComp)
|
layout := template.Layout(slot, userComp, user != nil, r.URL.Path)
|
||||||
|
|
||||||
render.RenderWithStatus(r, w, layout, status)
|
render.RenderWithStatus(r, w, layout, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (render *Render) getUserComp(user *types.User) templ.Component {
|
func (render *Render) getUserComp(user *types.User) templ.Component {
|
||||||
|
|
||||||
if user != nil {
|
if user != nil {
|
||||||
return auth.UserComp(user.Email)
|
return auth.UserComp(user.Email)
|
||||||
} else {
|
} else {
|
||||||
72
internal/handler/root_and_404.go
Normal file
72
internal/handler/root_and_404.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/internal/handler/middleware"
|
||||||
|
"spend-sparrow/internal/service"
|
||||||
|
"spend-sparrow/internal/template"
|
||||||
|
"spend-sparrow/internal/utils"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Index interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexImpl struct {
|
||||||
|
r *Render
|
||||||
|
c service.Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIndex(r *Render, c service.Clock) Index {
|
||||||
|
return IndexImpl{
|
||||||
|
r: r,
|
||||||
|
c: c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler IndexImpl) Handle(router *http.ServeMux) {
|
||||||
|
router.Handle("/", handler.handleRootAnd404())
|
||||||
|
router.Handle("/empty", handler.handleEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
comp = template.NotFound()
|
||||||
|
status = http.StatusNotFound
|
||||||
|
} else {
|
||||||
|
if user != nil {
|
||||||
|
utils.DoRedirect(w, r, "/dashboard")
|
||||||
|
return
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateSpan(r)
|
||||||
|
|
||||||
|
// Return nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
298
internal/handler/transaction.go
Normal file
298
internal/handler/transaction.go
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transaction interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransactionImpl struct {
|
||||||
|
s service.Transaction
|
||||||
|
account service.Account
|
||||||
|
treasureChest service.TreasureChest
|
||||||
|
r *Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, r *Render) Transaction {
|
||||||
|
return TransactionImpl{
|
||||||
|
s: s,
|
||||||
|
account: account,
|
||||||
|
treasureChest: treasureChest,
|
||||||
|
r: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h TransactionImpl) Handle(r *http.ServeMux) {
|
||||||
|
r.Handle("GET /transaction", h.handleTransactionPage())
|
||||||
|
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
|
||||||
|
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
|
||||||
|
r.Handle("POST /transaction/recalculate", h.handleRecalculate())
|
||||||
|
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := h.account.GetAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "new" {
|
||||||
|
comp := t.EditTransaction(nil, accounts, treasureChests)
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction, err := h.s.Get(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var comp templ.Component
|
||||||
|
if r.URL.Query().Get("edit") == "true" {
|
||||||
|
comp = t.EditTransaction(transaction, accounts, treasureChests)
|
||||||
|
} else {
|
||||||
|
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||||
|
comp = t.TransactionItem(transaction, accountMap, treasureChestMap)
|
||||||
|
}
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
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,
|
||||||
|
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 err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transaction, err = h.s.Update(r.Context(), user, input)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := h.account.GetAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
|
||||||
|
comp := t.TransactionItem(transaction, accountMap, treasureChestMap)
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.TriggerToastWithStatus(r.Context(), 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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
|
||||||
|
err := h.s.Delete(r.Context(), user, id)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureChests []*types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
|
||||||
|
accountMap := make(map[uuid.UUID]string, 0)
|
||||||
|
for _, account := range accounts {
|
||||||
|
accountMap[account.Id] = account.Name
|
||||||
|
}
|
||||||
|
treasureChestMap := make(map[uuid.UUID]string, 0)
|
||||||
|
root := ""
|
||||||
|
for _, treasureChest := range treasureChests {
|
||||||
|
if treasureChest.ParentId == nil {
|
||||||
|
root = treasureChest.Name + " > "
|
||||||
|
treasureChestMap[treasureChest.Id] = treasureChest.Name
|
||||||
|
} else {
|
||||||
|
treasureChestMap[treasureChest.Id] = root + treasureChest.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accountMap, treasureChestMap
|
||||||
|
}
|
||||||
136
internal/handler/transaction_recurring.go
Normal file
136
internal/handler/transaction_recurring.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
196
internal/handler/treasure_chest.go
Normal file
196
internal/handler/treasure_chest.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
50
internal/log/default.go
Normal file
50
internal/log/default.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
143
internal/otel.go
Normal file
143
internal/otel.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
219
internal/service/account.go
Normal file
219
internal/service/account.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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 = ×tamp
|
||||||
|
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
|
||||||
|
}
|
||||||
508
internal/service/auth.go
Normal file
508
internal/service/auth.go
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||||
|
ErrInvalidPassword = errors.New("the password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character")
|
||||||
|
ErrInvalidEmail = errors.New("invalid email")
|
||||||
|
ErrAccountExists = errors.New("account already exists")
|
||||||
|
ErrSessionIdInvalid = errors.New("session ID is invalid")
|
||||||
|
ErrTokenInvalid = errors.New("token is invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
DeleteAccount(ctx context.Context, user *types.User, currPass string) error
|
||||||
|
|
||||||
|
ChangePassword(ctx context.Context, 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
|
||||||
|
|
||||||
|
IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool
|
||||||
|
GetCsrfToken(ctx context.Context, session *types.Session) (string, error)
|
||||||
|
|
||||||
|
CleanupSessionsAndTokens(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthImpl struct {
|
||||||
|
db db.Auth
|
||||||
|
random Random
|
||||||
|
clock Clock
|
||||||
|
mail Mail
|
||||||
|
serverSettings *types.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl {
|
||||||
|
return &AuthImpl{
|
||||||
|
db: db,
|
||||||
|
random: random,
|
||||||
|
clock: clock,
|
||||||
|
mail: mail,
|
||||||
|
serverSettings: 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)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
} else {
|
||||||
|
return nil, nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := GetHashPassword(password, user.Salt)
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare(hash, user.Password) == 0 {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
newSession, err := service.createSession(ctx, user.Id)
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, error) {
|
||||||
|
session, err := service.createSession(ctx, uuid.Nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) {
|
||||||
|
_, err := mail.ParseAddress(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPasswordValid(password) {
|
||||||
|
return nil, ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := service.random.UUID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := service.random.Bytes(ctx, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := GetHashPassword(password, salt)
|
||||||
|
|
||||||
|
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
|
||||||
|
|
||||||
|
err = service.db.InsertUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrAlreadyExists) {
|
||||||
|
return nil, ErrAccountExists
|
||||||
|
} else {
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token *types.Token
|
||||||
|
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
token = tokens[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == nil {
|
||||||
|
newTokenStr, err := service.random.String(ctx, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token = types.NewToken(
|
||||||
|
userId,
|
||||||
|
"",
|
||||||
|
newTokenStr,
|
||||||
|
types.TokenTypeEmailVerify,
|
||||||
|
service.clock.Now(),
|
||||||
|
service.clock.Now().Add(24*time.Hour))
|
||||||
|
|
||||||
|
err = service.db.InsertToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
|
||||||
|
if tokenStr == "" {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := service.db.GetToken(ctx, tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := service.db.GetUser(ctx, token.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.Type != types.TokenTypeEmailVerify {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
now := service.clock.Now()
|
||||||
|
|
||||||
|
if token.ExpiresAt.Before(now) {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
user.EmailVerified = true
|
||||||
|
user.EmailVerifiedAt = &now
|
||||||
|
|
||||||
|
err = service.db.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = service.db.DeleteToken(ctx, token.Token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error {
|
||||||
|
return service.db.DeleteSession(ctx, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, currPass string) error {
|
||||||
|
userDb, err := service.db.GetUser(ctx, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
currHash := GetHashPassword(currPass, userDb.Salt)
|
||||||
|
if subtle.ConstantTimeCompare(currHash, userDb.Password) == 0 {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
err = service.db.DeleteUser(ctx, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
service.mail.SendMail(ctx, 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 {
|
||||||
|
if !isPasswordValid(newPass) {
|
||||||
|
return ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if currPass == newPass {
|
||||||
|
return ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
currHash := GetHashPassword(currPass, user.Salt)
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare(currHash, user.Password) == 0 {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash := GetHashPassword(newPass, user.Salt)
|
||||||
|
user.Password = newHash
|
||||||
|
|
||||||
|
err := service.db.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := service.db.GetSessions(ctx, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Id != sessionId {
|
||||||
|
err = service.db.DeleteSession(ctx, s.Id)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string) error {
|
||||||
|
tokenStr, err := service.random.String(ctx, 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := service.db.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(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))
|
||||||
|
|
||||||
|
err = service.db.InsertToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
service.mail.SendMail(ctx, email, "Reset Password", mail.String())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
|
||||||
|
if !isPasswordValid(newPass) {
|
||||||
|
return ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := service.db.GetToken(ctx, tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
err = service.db.DeleteToken(ctx, tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.Type != types.TokenTypePasswordReset ||
|
||||||
|
token.ExpiresAt.Before(service.clock.Now()) {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := service.db.GetUser(ctx, token.UserId)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Could not get user from token", "err", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
passHash := GetHashPassword(newPass, user.Salt)
|
||||||
|
|
||||||
|
user.Password = passHash
|
||||||
|
err = service.db.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := service.db.GetSessions(ctx, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, session := range sessions {
|
||||||
|
err = service.db.DeleteSession(ctx, session.Id)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
|
||||||
|
token, err := service.db.GetToken(ctx, tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if session == nil {
|
||||||
|
return "", types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
|
||||||
|
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
return tokens[0].Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, err := service.random.String(ctx, 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)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "CSRF-Token created", "token", 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") ||
|
||||||
|
!strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") ||
|
||||||
|
!strings.ContainsAny(password, "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?") {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ type Clock interface {
|
|||||||
|
|
||||||
type ClockImpl struct{}
|
type ClockImpl struct{}
|
||||||
|
|
||||||
func NewClockImpl() Clock {
|
func NewClock() Clock {
|
||||||
return &ClockImpl{}
|
return &ClockImpl{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ClockImpl) Now() time.Time {
|
func (c *ClockImpl) Now() time.Time {
|
||||||
return time.Now()
|
return time.Now().UTC()
|
||||||
}
|
}
|
||||||
175
internal/service/dashboard.go
Normal file
175
internal/service/dashboard.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
25
internal/service/default.go
Normal file
25
internal/service/default.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DECIMALS_MULTIPLIER = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateString(value string, fieldName string) error {
|
||||||
|
switch {
|
||||||
|
case value == "":
|
||||||
|
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)
|
||||||
|
case !safeInputRegex.MatchString(value):
|
||||||
|
return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, ErrBadRequest)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
8
internal/service/error.go
Normal file
8
internal/service/error.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBadRequest = errors.New("bad request")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
)
|
||||||
56
internal/service/mail.go
Normal file
56
internal/service/mail.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
internal/service/random_generator.go
Normal file
55
internal/service/random_generator.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
553
internal/service/transaction.go
Normal file
553
internal/service/transaction.go
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
527
internal/service/transaction_recurring.go
Normal file
527
internal/service/transaction_recurring.go
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
321
internal/service/treasure_chest.go
Normal file
321
internal/service/treasure_chest.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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 = ×tamp
|
||||||
|
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
|
||||||
|
}
|
||||||
124
internal/template/account/account.templ
Normal file
124
internal/template/account/account.templ
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import "spend-sparrow/internal/template/svg"
|
||||||
|
import "spend-sparrow/internal/types"
|
||||||
|
|
||||||
|
templ Account(accounts []*types.Account) {
|
||||||
|
<div class="max-w-6xl mt-10 mx-auto">
|
||||||
|
<button
|
||||||
|
hx-get="/account/new"
|
||||||
|
hx-target="#account-items"
|
||||||
|
hx-swap="afterbegin"
|
||||||
|
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
|
||||||
|
>
|
||||||
|
@svg.Plus()
|
||||||
|
<p>New Account</p>
|
||||||
|
</button>
|
||||||
|
<div id="account-items" class="my-6 flex flex-col items-center">
|
||||||
|
for _, account := range accounts {
|
||||||
|
@AccountItem(account)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ EditAccount(account *types.Account) {
|
||||||
|
{{
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
cancelUrl string
|
||||||
|
)
|
||||||
|
if account == nil {
|
||||||
|
name = ""
|
||||||
|
id = "new"
|
||||||
|
cancelUrl = "/empty"
|
||||||
|
} else {
|
||||||
|
name = account.Name
|
||||||
|
id = account.Id.String()
|
||||||
|
cancelUrl = "/account/" + id
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<form
|
||||||
|
hx-post={ "/account/" + id }
|
||||||
|
hx-target="closest #account"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="text-xl flex justify-end gap-4 items-center"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={ name }
|
||||||
|
placeholder="Account Name"
|
||||||
|
class="mr-auto bg-white input"
|
||||||
|
/>
|
||||||
|
<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 #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>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AccountItem(account *types.Account) {
|
||||||
|
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div 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>
|
||||||
|
} else {
|
||||||
|
<p class="mr-20 text-green-700">{ types.FormatEuros(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"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="button button-neglect px-1 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
@svg.Edit()
|
||||||
|
<span>
|
||||||
|
Edit
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
hx-delete={ "/account/" + account.Id.String() }
|
||||||
|
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>
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
1
internal/template/account/default.go
Normal file
1
internal/template/account/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package account
|
||||||
@@ -14,7 +14,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
|
|||||||
Change Password
|
Change Password
|
||||||
</h2>
|
</h2>
|
||||||
if !isPasswordReset {
|
if !isPasswordReset {
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
class="grow"
|
class="grow"
|
||||||
@@ -27,7 +27,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
class="grow"
|
class="grow"
|
||||||
@@ -39,7 +39,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary self-end">
|
<button class="button button-primary px-2 self-end">
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -14,15 +14,15 @@ if isSignIn {
|
|||||||
hx-target="#sign-in-or-up-error"
|
hx-target="#sign-in-or-up-error"
|
||||||
hx-post={ postUrl }
|
hx-post={ postUrl }
|
||||||
>
|
>
|
||||||
<h2 class="text-6xl mb-10">
|
<h2 class="text-4xl mb-4">
|
||||||
if isSignIn {
|
if isSignIn {
|
||||||
Sign In
|
Sign In
|
||||||
} else {
|
} else {
|
||||||
Sign Up
|
Sign Up
|
||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-5 w-5 opacity-70">
|
||||||
<path
|
<path
|
||||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
|
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
|
||||||
></path>
|
></path>
|
||||||
@@ -39,10 +39,11 @@ if isSignIn {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
|
autofocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-5 w-5 opacity-70">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
||||||
@@ -60,16 +61,20 @@ if isSignIn {
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex justify-end items-center gap-2">
|
<div class="flex justify-end items-center gap-3 h-14">
|
||||||
if isSignIn {
|
if isSignIn {
|
||||||
<a href="/auth/forgot-password" class="grow link text-gray-500 text-sm">Forgot Password?</a>
|
<a href="/auth/forgot-password" class="text-gray-500 text-sm px-1 button button-neglect">
|
||||||
<a href="/auth/signup" class="link text-gray-500 text-sm">Don't have an account? Sign Up</a>
|
Forgot
|
||||||
<button class="btn btn-primary">
|
Password?
|
||||||
Sign In
|
</a>
|
||||||
</button>
|
<a href="/auth/signup" class="ml-auto text-gray-500 text-sm px-1 button button-neglect">
|
||||||
|
Don't have an account?
|
||||||
|
Sign Up
|
||||||
|
</a>
|
||||||
|
<button class="button button-primary text-gray-600 text-2xl px-1">Sign In</button>
|
||||||
} else {
|
} else {
|
||||||
<a href="/auth/signin" class="link text-gray-500 text-sm">Already have an account? Sign In</a>
|
<a href="/auth/signin" class="text-gray-500 text-sm px-1 button button-neglect">Already have an account? Sign In</a>
|
||||||
<button class="btn btn-primary self-end">
|
<button class="button button-primary text-gray-600 text-2xl px-1">
|
||||||
Sign Up
|
Sign Up
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
44
internal/template/auth/user.templ
Normal file
44
internal/template/auth/user.templ
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
templ UserComp(user string) {
|
||||||
|
<div id="user-info" class="flex items-center gap-2 text-nowrap">
|
||||||
|
if user != "" {
|
||||||
|
<div class="inline-block group relative">
|
||||||
|
<button class="font-semibold py-2 px-4 inline-flex items-center">
|
||||||
|
<span class="mr-1">{ user }</span>
|
||||||
|
<!-- SVG is arrow down -->
|
||||||
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="absolute hidden group-has-hover:block w-full z-2">
|
||||||
|
<ul class="w-fit float-right mr-4 p-3 border-2 border-gray-200 rounded-lg bg-white shadow-lg">
|
||||||
|
<li class="mb-1">
|
||||||
|
<a class="button w-full px-1 button-neglect block" hx-post="/api/auth/signout" hx-target="#user-info">Sign Out</a>
|
||||||
|
</li>
|
||||||
|
<li class="mb-4">
|
||||||
|
<a class="button w-full px-1 button-neglect block" href="/auth/change-password">Change Password</a>
|
||||||
|
</li>
|
||||||
|
<li class="mb-4">
|
||||||
|
<button
|
||||||
|
hx-post="/transaction/recalculate"
|
||||||
|
hx-swap="none"
|
||||||
|
type="button"
|
||||||
|
class="button text-left w-full px-1 button-neglect block mt-4"
|
||||||
|
>Recalculate</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="button w-full px-1 button-neglect text-gray-400 block" href="/auth/delete-account">
|
||||||
|
Delete
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<a href="/auth/signup" class="text-xl button px-1 button-neglect">Sign Up</a>
|
||||||
|
<a href="/auth/signin" class="text-xl button px-1 button-neglect">Sign In</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
templ VerifyComp() {
|
templ VerifyComp() {
|
||||||
<main>
|
<main class="h-full">
|
||||||
<div class="flex flex-col items-center justify-center h-screen">
|
<div class=" flex flex-col items-center justify-center h-full">
|
||||||
<h2 class="text-6xl mb-10">
|
<h2 class="text-6xl mb-10">
|
||||||
Verify your email
|
Verify your email
|
||||||
</h2>
|
</h2>
|
||||||
@@ -12,7 +12,12 @@ templ VerifyComp() {
|
|||||||
<p class="text-lg text-center">
|
<p class="text-lg text-center">
|
||||||
Please check your inbox/spam and click on the link to verify your account.
|
Please check your inbox/spam and click on the link to verify your account.
|
||||||
</p>
|
</p>
|
||||||
<button class="mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
|
<button
|
||||||
|
class="mt-8 button button-normal px-2 text-gray-500 text-xl"
|
||||||
|
hx-get="/api/auth/verify-resend"
|
||||||
|
hx-sync="this:drop"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
resend verification email
|
resend verification email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
32
internal/template/dashboard/dashboard.templ
Normal file
32
internal/template/dashboard/dashboard.templ
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
2
internal/template/dashboard/default.go
Normal file
2
internal/template/dashboard/default.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
13
internal/template/index.templ
Normal file
13
internal/template/index.templ
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package template
|
||||||
|
|
||||||
|
templ Index() {
|
||||||
|
<div class="h-full flex flex-col items-center justify-center">
|
||||||
|
<h1 class="flex gap-2 w-full justify-center">
|
||||||
|
<img width="600" src="/static/logo.svg" alt="SpendSparrow logo"/>
|
||||||
|
</h1>
|
||||||
|
<h2 class="text-2xl mt-8 text-gray-800">
|
||||||
|
Spend your <span class="px-2 text-3xl text-yellow-800">treasure</span> on the important
|
||||||
|
</h2>
|
||||||
|
<a href="/auth/signup" class="mt-24 button button-primary text-2xl p-4 font-bold">Getting Started</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
95
internal/template/layout.templ
Normal file
95
internal/template/layout.templ
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
22
internal/template/mail/register.templ
Normal file
22
internal/template/mail/register.templ
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package mail;
|
||||||
|
|
||||||
|
import "net/url"
|
||||||
|
|
||||||
|
templ Register(baseUrl string, token string) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Welcome</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h4>Thank you for Sign Up!</h4>
|
||||||
|
<p>
|
||||||
|
Click <a href={ templ.URL(baseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to finalize
|
||||||
|
your registration.
|
||||||
|
</p>
|
||||||
|
<p>Kind regards</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ templ NotFound() {
|
|||||||
<div class="p-16 rounded-lg">
|
<div class="p-16 rounded-lg">
|
||||||
<h1 class="text-4xl mb-5">Not Found</h1>
|
<h1 class="text-4xl mb-5">Not Found</h1>
|
||||||
<p class="text-lg mb-5">The page you are looking for does not exist.</p>
|
<p class="text-lg mb-5">The page you are looking for does not exist.</p>
|
||||||
<a href="/" class="">Go back to home</a>
|
<a href="/" class="button button-primary text-2xl py-2 px-4 mt-10">Go back to home</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
59
internal/template/svg/default.templ
Normal file
59
internal/template/svg/default.templ
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
templ Edit() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 384" class="h-4 w-4 text-gray-500">
|
||||||
|
<path fill="currentColor" d="M0 304L236 68l80 80L80 384H0v-80zM378 86l-39 39l-80-80l39-39q6-6 15-6t15 6l50 50q6 6 6 15t-6 15z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Delete() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="h-4 w-4 text-gray-500">
|
||||||
|
<path fill="currentColor" d="M21 341V85h256v256q0 18-12.5 30.5T235 384H64q-18 0-30.5-12.5T21 341zM299 21v43H0V21h75L96 0h107l21 21h75z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Eye() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 472 384" class="h-4 w-4 text-gray-500">
|
||||||
|
<path fill="currentColor" d="M235 32q79 0 142.5 44.5T469 192q-28 71-91.5 115.5T235 352T92 307.5T0 192q28-71 92-115.5T235 32zm0 267q44 0 75-31.5t31-75.5t-31-75.5T235 85t-75.5 31.5T128 192t31.5 75.5T235 299zm-.5-171q26.5 0 45.5 18.5t19 45.5t-19 45.5t-45.5 18.5t-45-18.5T171 192t18.5-45.5t45-18.5z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Plus() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="h-4 w-4 text-gray-500">
|
||||||
|
<path fill="currentColor" d="M299 213H171v128h-43V213H0v-42h128V43h43v128h128v42z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Save() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4 text-gray-500">
|
||||||
|
<path fill="currentColor" d="M21 7v12q0 .825-.588 1.413T19 21H5q-.825 0-1.413-.588T3 19V5q0-.825.588-1.413T5 3h12l4 4Zm-9 11q1.25 0 2.125-.875T15 15q0-1.25-.875-2.125T12 12q-1.25 0-2.125.875T9 15q0 1.25.875 2.125T12 18Zm-6-8h9V6H6v4Z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Cancel() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" class="text-gray-500">
|
||||||
|
<path fill="currentColor" d="m654 501l346 346l-154 154l-346-346l-346 346L0 847l346-346L0 155L154 1l346 346L846 1l154 154z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Info() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="h-6 w-6 text-blue-700">
|
||||||
|
<mask id="ipSInfo0">
|
||||||
|
<g fill="none">
|
||||||
|
<path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="4" d="M24 44a19.937 19.937 0 0 0 14.142-5.858A19.937 19.937 0 0 0 44 24a19.938 19.938 0 0 0-5.858-14.142A19.937 19.937 0 0 0 24 4A19.938 19.938 0 0 0 9.858 9.858A19.938 19.938 0 0 0 4 24a19.937 19.937 0 0 0 5.858 14.142A19.938 19.938 0 0 0 24 44Z"></path>
|
||||||
|
<path fill="#000" fill-rule="evenodd" d="M24 11a2.5 2.5 0 1 1 0 5a2.5 2.5 0 0 1 0-5Z" clip-rule="evenodd"></path><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24.5 34V20h-2M21 34h7"></path>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipSInfo0)"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Menu() {
|
||||||
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" class="text-gray-500">
|
||||||
|
<g data-name="1" id="_1">
|
||||||
|
<path d="M441.13,166.52h-372a15,15,0,1,1,0-30h372a15,15,0,0,1,0,30Z"></path>
|
||||||
|
<path d="M441.13,279.72h-372a15,15,0,1,1,0-30h372a15,15,0,0,1,0,30Z"></path>
|
||||||
|
<path d="M441.13,392.92h-372a15,15,0,1,1,0-30h372a15,15,0,0,1,0,30Z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
1
internal/template/transaction/default.go
Normal file
1
internal/template/transaction/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package transaction
|
||||||
317
internal/template/transaction/transaction.templ
Normal file
317
internal/template/transaction/transaction.templ
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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">
|
||||||
|
<
|
||||||
|
</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">
|
||||||
|
>
|
||||||
|
</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">
|
||||||
|
<
|
||||||
|
</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">
|
||||||
|
>
|
||||||
|
</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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
if transaction.TreasureChestId != nil {
|
||||||
|
{ treasureChests[*transaction.TreasureChestId] }
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
if transaction.Party != "" {
|
||||||
|
{ transaction.Party }
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
if transaction.Description != "" {
|
||||||
|
{ transaction.Description }
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
</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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/template/transaction_recurring/default.go
Normal file
1
internal/template/transaction_recurring/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package transaction_recurring
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
1
internal/template/treasurechest/default.go
Normal file
1
internal/template/treasurechest/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package treasurechest
|
||||||
190
internal/template/treasurechest/treasure_chest.templ
Normal file
190
internal/template/treasurechest/treasure_chest.templ
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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"> 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
|
||||||
|
}
|
||||||
27
internal/types/account.go
Normal file
27
internal/types/account.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Account holds money.
|
||||||
|
type Account struct {
|
||||||
|
Id uuid.UUID `db:"id"`
|
||||||
|
UserId uuid.UUID `db:"user_id"`
|
||||||
|
|
||||||
|
// Custom Name of the account, e.g. "Bank", "Cash", "Credit Card"
|
||||||
|
Name string `db:"name"`
|
||||||
|
|
||||||
|
CurrentBalance int64 `db:"current_balance"`
|
||||||
|
LastTransaction *time.Time `db:"last_transaction"`
|
||||||
|
// The current precalculated value of:
|
||||||
|
// Account.Balance - [PiggyBank.Balance...]
|
||||||
|
OinkBalance int64 `db:"oink_balance"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
CreatedBy uuid.UUID `db:"created_by"`
|
||||||
|
UpdatedAt *time.Time `db:"updated_at"`
|
||||||
|
UpdatedBy *uuid.UUID `db:"updated_by"`
|
||||||
|
}
|
||||||
@@ -17,7 +17,15 @@ type User struct {
|
|||||||
CreateAt time.Time
|
CreateAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt *time.Time, isAdmin bool, password []byte, salt []byte, createAt time.Time) *User {
|
func NewUser(
|
||||||
|
id uuid.UUID,
|
||||||
|
email string,
|
||||||
|
emailVerified bool,
|
||||||
|
emailVerifiedAt *time.Time,
|
||||||
|
isAdmin bool,
|
||||||
|
password []byte,
|
||||||
|
salt []byte,
|
||||||
|
createAt time.Time) *User {
|
||||||
return &User{
|
return &User{
|
||||||
Id: id,
|
Id: id,
|
||||||
Email: email,
|
Email: email,
|
||||||
@@ -31,10 +39,10 @@ func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt *ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Id string
|
Id string `db:"session_id"`
|
||||||
UserId uuid.UUID
|
UserId uuid.UUID `db:"user_id"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `db:"created_at"`
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSession(id string, userId uuid.UUID, createdAt time.Time, expiresAt time.Time) *Session {
|
func NewSession(id string, userId uuid.UUID, createdAt time.Time, expiresAt time.Time) *Session {
|
||||||
@@ -63,7 +71,13 @@ var (
|
|||||||
TokenTypeCsrf TokenType = "csrf"
|
TokenTypeCsrf TokenType = "csrf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewToken(userId uuid.UUID, sessionId string, token string, tokenType TokenType, createdAt time.Time, expiresAt time.Time) *Token {
|
func NewToken(
|
||||||
|
userId uuid.UUID,
|
||||||
|
sessionId string,
|
||||||
|
token string,
|
||||||
|
tokenType TokenType,
|
||||||
|
createdAt time.Time,
|
||||||
|
expiresAt time.Time) *Token {
|
||||||
return &Token{
|
return &Token{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
SessionId: sessionId,
|
SessionId: sessionId,
|
||||||
30
internal/types/dashboard.go
Normal file
30
internal/types/dashboard.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
36
internal/types/format.go
Normal file
36
internal/types/format.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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 + " €"
|
||||||
|
}
|
||||||
109
internal/types/settings.go
Normal file
109
internal/types/settings.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
55
internal/types/transaction.go
Normal file
55
internal/types/transaction.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
38
internal/types/transaction_recurring.go
Normal file
38
internal/types/transaction_recurring.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
27
internal/types/treasure_chest.go
Normal file
27
internal/types/treasure_chest.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The TreasureChest is a fictional account.
|
||||||
|
// The money it "holds" distributed across all accounts
|
||||||
|
//
|
||||||
|
// At the time of writing this, linking it to a specific account doesn't really make sense
|
||||||
|
// Imagine a TreasureChest for free time activities, where some money is spend in cash and some other with credit card.
|
||||||
|
type TreasureChest struct {
|
||||||
|
Id uuid.UUID `db:"id"`
|
||||||
|
ParentId *uuid.UUID `db:"parent_id"`
|
||||||
|
UserId uuid.UUID `db:"user_id"`
|
||||||
|
|
||||||
|
Name string `db:"name"`
|
||||||
|
|
||||||
|
CurrentBalance int64 `db:"current_balance"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
CreatedBy uuid.UUID `db:"created_by"`
|
||||||
|
UpdatedAt *time.Time `db:"updated_at"`
|
||||||
|
UpdatedBy *uuid.UUID `db:"updated_by"`
|
||||||
|
}
|
||||||
10
internal/types/types.go
Normal file
10
internal/types/types.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInternal = errors.New("internal server error")
|
||||||
|
ErrUnauthorized = errors.New("you are not authorized to perform this action")
|
||||||
|
)
|
||||||
42
internal/utils/http.go
Normal file
42
internal/utils/http.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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: "mefit_error_total",
|
|
||||||
Help: "The total number of errors during processing",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
func Fatal(message string, args ...interface{}) {
|
|
||||||
s := format(message, args)
|
|
||||||
log.Fatal(s)
|
|
||||||
|
|
||||||
errorMetric.Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Error(message string, args ...interface{}) {
|
|
||||||
s := format(message, args)
|
|
||||||
slog.Error(s)
|
|
||||||
|
|
||||||
errorMetric.Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
141
main.go
141
main.go
@@ -1,136 +1,41 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"spend-sparrow/db"
|
|
||||||
"spend-sparrow/handler"
|
|
||||||
"spend-sparrow/handler/middleware"
|
|
||||||
"spend-sparrow/log"
|
|
||||||
"spend-sparrow/service"
|
|
||||||
"spend-sparrow/types"
|
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"spend-sparrow/internal"
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/uptrace/opentelemetry-go-extra/otelsql"
|
||||||
|
"github.com/uptrace/opentelemetry-go-extra/otelsqlx"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
err := godotenv.Load()
|
err := godotenv.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error loading .env file")
|
slog.ErrorContext(ctx, "Error loading .env file")
|
||||||
}
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", "./data.db")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Could not open Database data.db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
run(context.Background(), db, os.Getenv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(ctx context.Context, database *sql.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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
|
||||||
shutdownCtx := context.Background()
|
otelsql.WithAttributes(semconv.DBSystemSqlite))
|
||||||
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
if err != nil {
|
||||||
defer cancel()
|
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
|
||||||
if err := s.Shutdown(shutdownCtx); err != nil {
|
return
|
||||||
log.Error("error shutting down http server: %v", err)
|
}
|
||||||
} else {
|
defer func() {
|
||||||
log.Info("Gracefully stopped http server on %v", s.Addr)
|
if err = db.Close(); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Database close failed", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = internal.Run(context.Background(), db, "", os.Getenv); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Error running server", "err", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
|
|
||||||
var router = http.NewServeMux()
|
|
||||||
|
|
||||||
authDb := db.NewAuthSqlite(d)
|
|
||||||
workoutDb := db.NewWorkoutDbSqlite(d)
|
|
||||||
|
|
||||||
randomService := service.NewRandomImpl()
|
|
||||||
clockService := service.NewClockImpl()
|
|
||||||
mailService := service.NewMailImpl(serverSettings)
|
|
||||||
|
|
||||||
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
|
|
||||||
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
|
|
||||||
|
|
||||||
render := handler.NewRender()
|
|
||||||
indexHandler := handler.NewIndex(authService, render)
|
|
||||||
authHandler := handler.NewAuth(authService, render)
|
|
||||||
workoutHandler := handler.NewWorkout(workoutService, authService, render)
|
|
||||||
|
|
||||||
indexHandler.Handle(router)
|
|
||||||
workoutHandler.Handle(router)
|
|
||||||
authHandler.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.Log,
|
|
||||||
middleware.CacheControl,
|
|
||||||
middleware.SecurityHeaders(serverSettings),
|
|
||||||
middleware.Authenticate(authService),
|
|
||||||
middleware.CrossSiteRequestForgery(authService),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
1746
main_test.go
1746
main_test.go
File diff suppressed because it is too large
Load Diff
@@ -35,10 +35,3 @@ CREATE TABLE token (
|
|||||||
expires_at DATETIME
|
expires_at DATETIME
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE workout (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
sets INTEGER NOT NULL,
|
|
||||||
reps INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|||||||
17
migration/002_account.up.sql
Normal file
17
migration/002_account.up.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
CREATE TABLE account (
|
||||||
|
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
current_balance INTEGER NOT NULL,
|
||||||
|
last_transaction DATETIME,
|
||||||
|
oink_balance INTEGER NOT NULL,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_at DATETIME,
|
||||||
|
updated_by TEXT
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
16
migration/003_treasure_chest.up.sql
Normal file
16
migration/003_treasure_chest.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
CREATE TABLE treasure_chest (
|
||||||
|
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
parent_id TEXT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
current_balance INTEGER NOT NULL,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_at DATETIME,
|
||||||
|
updated_by TEXT
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
17
migration/004_transaction.up.sql
Normal file
17
migration/004_transaction.up.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
CREATE TABLE "transaction" (
|
||||||
|
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
|
||||||
|
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;
|
||||||
2
migration/005_transaction_add_error.up.sql
Normal file
2
migration/005_transaction_add_error.up.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN error TEXT;
|
||||||
4
migration/006_transaction_description.up.sql
Normal file
4
migration/006_transaction_description.up.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
ALTER TABLE "transaction" DROP COLUMN note;
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN party TEXT;
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN description TEXT;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user