Compare commits
197 Commits
81a03ca2c1
...
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 |
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- run: docker build . -t spend-sparrow-test
|
||||
- run: docker rmi spend-sparrow-test
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
||||
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
|
||||
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,12 +25,13 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
*.db
|
||||
data/
|
||||
secrets/
|
||||
|
||||
node_modules/
|
||||
static/css/tailwind.css
|
||||
static/js/htmx.min.js
|
||||
static/js/echarts.min.js
|
||||
tmp/
|
||||
|
||||
mocks/*
|
||||
|
||||
@@ -23,6 +23,10 @@ linters:
|
||||
- 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
|
||||
issue-845-fix: True
|
||||
packages:
|
||||
spend-sparrow/service:
|
||||
spend-sparrow/internal/service:
|
||||
interfaces:
|
||||
Random:
|
||||
Clock:
|
||||
Mail:
|
||||
spend-sparrow/db:
|
||||
spend-sparrow/internal/db:
|
||||
interfaces:
|
||||
Auth:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24.3@sha256:4c0a1814a7c6c65ece28b3bfea14ee3cf83b5e80b81418453f0e9d5255a5d7b8 AS builder_go
|
||||
FROM golang:1.25.3@sha256:7e3cbcd2f6af1bebb937462ec29f77ce28b406081af509afed158fa8721f11af AS builder_go
|
||||
WORKDIR /spend-sparrow
|
||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
@@ -13,7 +13,7 @@ RUN golangci-lint run ./...
|
||||
RUN go build -o /spend-sparrow/spend-sparrow .
|
||||
|
||||
|
||||
FROM node:22.16.0@sha256:0b5b940c21ab03353de9042f9166c75bcfc53c4cd0508c7fd88576646adbf875 AS builder_node
|
||||
FROM node:24.11.0@sha256:e5bbac0e9b8a6e3b96a86a82bbbcf4c533a879694fd613ed616bae5116f6f243 AS builder_node
|
||||
WORKDIR /spend-sparrow
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm clean-install
|
||||
@@ -21,7 +21,7 @@ COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM debian:12.11@sha256:bd73076dc2cd9c88f48b5b358328f24f2a4289811bd73787c031e20db9f97123
|
||||
FROM debian:13.1@sha256:01a723bf5bfb21b9dda0c9a33e0538106e4d02cce8f557e118dd61259553d598
|
||||
WORKDIR /spend-sparrow
|
||||
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
||||
COPY migration ./migration
|
||||
|
||||
10
Readme.md
10
Readme.md
@@ -3,6 +3,15 @@
|
||||
|
||||
SpendSparrow is a web app to keep track of expenses and income. It is very opinionated by keeping an keen eye on disciplin of it's users. Every Expense needs to be mapped to a Piggy Bank. For emergencies, funds can be moved between Piggy Banks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
```bash
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
go install github.com/vektra/mockery/v2@latest
|
||||
```
|
||||
|
||||
## Design priciples
|
||||
|
||||
The State of the application can always be calculated on the fly. Even though it is not an Event Streaming Application, it is still important to be able to recalculate historic data.
|
||||
@@ -10,3 +19,4 @@ It may be applicable to do some sort of monthly snapshots to speed up calculatio
|
||||
|
||||
This applications uses as little dependencies as feasible, especially on the front end.
|
||||
|
||||
|
||||
|
||||
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 |
394
db/auth.go
394
db/auth.go
@@ -1,394 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type Auth interface {
|
||||
InsertUser(user *types.User) error
|
||||
UpdateUser(user *types.User) error
|
||||
GetUserByEmail(email string) (*types.User, error)
|
||||
GetUser(userId uuid.UUID) (*types.User, error)
|
||||
DeleteUser(userId uuid.UUID) error
|
||||
|
||||
InsertToken(token *types.Token) error
|
||||
GetToken(token string) (*types.Token, error)
|
||||
GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
|
||||
GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error)
|
||||
DeleteToken(token string) error
|
||||
|
||||
InsertSession(session *types.Session) error
|
||||
GetSession(sessionId string) (*types.Session, error)
|
||||
GetSessions(userId uuid.UUID) ([]*types.Session, error)
|
||||
DeleteSession(sessionId string) error
|
||||
DeleteOldSessions(userId uuid.UUID) error
|
||||
}
|
||||
|
||||
type AuthSqlite struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewAuthSqlite(db *sqlx.DB) *AuthSqlite {
|
||||
return &AuthSqlite{db: db}
|
||||
}
|
||||
|
||||
func (db AuthSqlite) InsertUser(user *types.User) error {
|
||||
_, err := db.db.Exec(`
|
||||
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
user.Id, user.Email, user.EmailVerified, user.EmailVerifiedAt, user.IsAdmin, user.Password, user.Salt, user.CreateAt)
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "email") {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
|
||||
log.Error("SQL error InsertUser: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) UpdateUser(user *types.User) error {
|
||||
_, err := db.db.Exec(`
|
||||
UPDATE user
|
||||
SET email_verified = ?, email_verified_at = ?, password = ?
|
||||
WHERE user_id = ?`,
|
||||
user.EmailVerified, user.EmailVerifiedAt, user.Password, user.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Error("SQL error UpdateUser: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
|
||||
var (
|
||||
userId uuid.UUID
|
||||
emailVerified bool
|
||||
emailVerifiedAt *time.Time
|
||||
isAdmin bool
|
||||
password []byte
|
||||
salt []byte
|
||||
createdAt time.Time
|
||||
)
|
||||
|
||||
err := db.db.QueryRow(`
|
||||
SELECT user_id, email_verified, email_verified_at, password, salt, created_at
|
||||
FROM user
|
||||
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
||||
if err != nil {
|
||||
if errors.Is(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 errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
} else {
|
||||
log.Error("SQL error GetUser %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
|
||||
tx, err := db.db.Begin()
|
||||
if err != nil {
|
||||
log.Error("Could not start transaction: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM account WHERE user_id = ?", userId)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Error("Could not delete accounts: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM token WHERE user_id = ?", userId)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Error("Could not delete user tokens: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM session WHERE user_id = ?", userId)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Error("Could not delete sessions: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM user WHERE user_id = ?", userId)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Error("Could not delete user: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM treasure_chest WHERE user_id = ?", userId)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Error("Could not delete user: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM \"transaction\" WHERE user_id = ?", userId)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Error("Could not delete user: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
log.Error("Could not commit transaction: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) InsertToken(token *types.Token) error {
|
||||
_, err := db.db.Exec(`
|
||||
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
|
||||
|
||||
if err != nil {
|
||||
log.Error("Could not insert token: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) GetToken(token string) (*types.Token, error) {
|
||||
var (
|
||||
userId uuid.UUID
|
||||
sessionId string
|
||||
tokenType types.TokenType
|
||||
createdAtStr string
|
||||
expiresAtStr string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
)
|
||||
|
||||
err := db.db.QueryRow(`
|
||||
SELECT user_id, session_id, type, created_at, expires_at
|
||||
FROM token
|
||||
WHERE token = ?`, token).Scan(&userId, &sessionId, &tokenType, &createdAtStr, &expiresAtStr)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Info("Token '%v' not found", token)
|
||||
return nil, ErrNotFound
|
||||
} else {
|
||||
log.Error("Could not get token: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
|
||||
if err != nil {
|
||||
log.Error("Could not parse token.created_at: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
|
||||
if err != nil {
|
||||
log.Error("Could not parse token.expires_at: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
|
||||
query, err := db.db.Query(`
|
||||
SELECT token, created_at, expires_at
|
||||
FROM token
|
||||
WHERE user_id = ?
|
||||
AND type = ?`, userId, tokenType)
|
||||
|
||||
if err != nil {
|
||||
log.Error("Could not get token: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
return getTokensFromQuery(query, userId, "", tokenType)
|
||||
}
|
||||
|
||||
func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
|
||||
query, err := db.db.Query(`
|
||||
SELECT token, created_at, expires_at
|
||||
FROM token
|
||||
WHERE session_id = ?
|
||||
AND type = ?`, sessionId, tokenType)
|
||||
|
||||
if err != nil {
|
||||
log.Error("Could not get token: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
return getTokensFromQuery(query, uuid.Nil, sessionId, tokenType)
|
||||
}
|
||||
|
||||
func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
|
||||
var tokens []*types.Token
|
||||
|
||||
hasRows := false
|
||||
for query.Next() {
|
||||
hasRows = true
|
||||
|
||||
var (
|
||||
token string
|
||||
createdAtStr string
|
||||
expiresAtStr string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
)
|
||||
|
||||
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
|
||||
if err != nil {
|
||||
log.Error("Could not scan token: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
|
||||
if err != nil {
|
||||
log.Error("Could not parse token.created_at: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
|
||||
if err != nil {
|
||||
log.Error("Could not parse token.expires_at: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
|
||||
}
|
||||
|
||||
if !hasRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) DeleteToken(token string) error {
|
||||
_, err := db.db.Exec("DELETE FROM token WHERE token = ?", token)
|
||||
if err != nil {
|
||||
log.Error("Could not delete token: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) InsertSession(session *types.Session) error {
|
||||
_, err := db.db.Exec(`
|
||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
|
||||
|
||||
if err != nil {
|
||||
log.Error("Could not insert new session %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) GetSession(sessionId string) (*types.Session, error) {
|
||||
var (
|
||||
userId uuid.UUID
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
)
|
||||
|
||||
err := db.db.QueryRow(`
|
||||
SELECT user_id, created_at, expires_at
|
||||
FROM session
|
||||
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
|
||||
|
||||
if err != nil {
|
||||
log.Warn("Session \"%s\" not found: %v", sessionId, err)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
|
||||
}
|
||||
|
||||
func (db AuthSqlite) GetSessions(userId uuid.UUID) ([]*types.Session, error) {
|
||||
var sessions []*types.Session
|
||||
err := db.db.Select(&sessions, `
|
||||
SELECT *
|
||||
FROM session
|
||||
WHERE user_id = ?`, userId)
|
||||
if err != nil {
|
||||
log.Error("Could not get sessions: %v", err)
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
return sessions, 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,40 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func RunMigrations(db *sqlx.DB, pathPrefix string) error {
|
||||
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
log.Error("Could not create Migration instance: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://"+pathPrefix+"migration/",
|
||||
"",
|
||||
driver)
|
||||
if err != nil {
|
||||
log.Error("Could not create migrations instance: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if !errors.Is(err, migrate.ErrNoChange) {
|
||||
log.Error("Could not run migrations: %v", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
6
dev.sh
6
dev.sh
@@ -1,3 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
go install github.com/vektra/mockery/v2@latest
|
||||
|
||||
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." &
|
||||
npm run watch
|
||||
|
||||
|
||||
54
go.mod
54
go.mod
@@ -1,36 +1,54 @@
|
||||
module spend-sparrow
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.3
|
||||
toolchain go1.25.3
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.865
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3
|
||||
github.com/a-h/templ v0.3.960
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/net v0.40.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||
go.opentelemetry.io/otel/log v0.14.0
|
||||
go.opentelemetry.io/otel/sdk v1.38.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
||||
go.opentelemetry.io/otel/trace v1.38.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/grpc v1.75.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
117
go.sum
117
go.sum
@@ -1,21 +1,30 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A=
|
||||
github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
|
||||
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
||||
github.com/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -25,47 +34,79 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
|
||||
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/service"
|
||||
"spend-sparrow/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnauthorized):
|
||||
utils.TriggerToastWithStatus(w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
|
||||
return
|
||||
case errors.Is(err, service.ErrBadRequest):
|
||||
utils.TriggerToastWithStatus(w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
|
||||
return
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
utils.TriggerToastWithStatus(w, r, "error", extractErrorMessage(err), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func extractErrorMessage(err error) string {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.SplitN(errMsg, ":", 2)[0]
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"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 {
|
||||
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", csrfToken)
|
||||
}
|
||||
|
||||
return rr.ResponseWriter.Write([]byte(dataStr))
|
||||
}
|
||||
|
||||
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSession(r)
|
||||
|
||||
if r.Method == http.MethodPost ||
|
||||
r.Method == http.MethodPut ||
|
||||
r.Method == http.MethodDelete ||
|
||||
r.Method == http.MethodPatch {
|
||||
csrfToken := r.Header.Get("Csrf-Token")
|
||||
|
||||
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(csrfToken, session.Id) {
|
||||
log.Info("CSRF-Token \"%s\" not correct", csrfToken)
|
||||
if r.Header.Get("Hx-Request") == "true" {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
responseWriter := newCsrfResponseWriter(w, auth, session)
|
||||
next.ServeHTTP(responseWriter, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/service"
|
||||
)
|
||||
|
||||
func GenerateRecurringTransactions(transactionRecurring service.TransactionRecurring) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUser(r)
|
||||
if user == nil || r.Method != http.MethodGet {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = transactionRecurring.GenerateTransactions(user)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, 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.StatusCode = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func Log(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
wrapped := &WrappedWriter{
|
||||
ResponseWriter: w,
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
log.Info(r.RemoteAddr + " " + strconv.Itoa(wrapped.StatusCode) + " " + r.Method + " " + r.URL.Path + " " + time.Since(start).String())
|
||||
metrics.WithLabelValues(r.URL.Path, r.Method, http.StatusText(wrapped.StatusCode)).Inc()
|
||||
})
|
||||
}
|
||||
33
input.css
33
input.css
@@ -3,37 +3,34 @@
|
||||
@source './static/**/*.js';
|
||||
@source './template/**/*.templ';
|
||||
|
||||
body {
|
||||
@apply font-shippori text-gray-700;
|
||||
@font-face {
|
||||
font-family: "EB Garamond";
|
||||
src: url("/static/font/EBGaramond-VariableFont_wght.woff2") format("woff2");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "EB Garamond", serif;
|
||||
@apply text-gray-700;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
@apply outline-none ring-0;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Pirata One";
|
||||
src: url("/static/font/pirata_one/PirataOne-Regular.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Shippori Mincho";
|
||||
src: url("/static/font/shippori_mincho/ShipporiMincho-Medium.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-pirata: "Pirata One", sans-serif;
|
||||
--font-shippori: "Shippori Mincho", sans-serif;
|
||||
button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
transition: all 150ms linear;
|
||||
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);
|
||||
transform: translate(-0.25rem, -0.25rem);
|
||||
box-shadow: 3px 3px 3px var(--color-gray-200);
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
@@ -61,3 +58,5 @@ input:focus {
|
||||
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
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -12,24 +13,24 @@ var (
|
||||
ErrAlreadyExists = errors.New("row already exists")
|
||||
)
|
||||
|
||||
func TransformAndLogDbError(module string, r sql.Result, err error) error {
|
||||
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
log.Error("%v: %v", module, err)
|
||||
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
if r != nil {
|
||||
rows, err := r.RowsAffected()
|
||||
if err != nil {
|
||||
log.Error("%v: %v", module, err)
|
||||
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
log.Info("%v: not found", module)
|
||||
slog.InfoContext(ctx, "row not found", "module", module)
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/service"
|
||||
t "spend-sparrow/template/account"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/utils"
|
||||
"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"
|
||||
)
|
||||
@@ -36,13 +36,15 @@ func (h AccountImpl) Handle(r *http.ServeMux) {
|
||||
|
||||
func (h AccountImpl) handleAccountPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := h.s.GetAll(user)
|
||||
accounts, err := h.s.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -55,6 +57,8 @@ func (h AccountImpl) handleAccountPage() http.HandlerFunc {
|
||||
|
||||
func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -68,7 +72,7 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.s.Get(user, id)
|
||||
account, err := h.s.Get(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -86,6 +90,8 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
|
||||
|
||||
func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -99,13 +105,13 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
|
||||
id := r.PathValue("id")
|
||||
name := r.FormValue("name")
|
||||
if id == "new" {
|
||||
account, err = h.s.Add(user, name)
|
||||
account, err = h.s.Add(r.Context(), user, name)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
account, err = h.s.UpdateName(user, id, name)
|
||||
account, err = h.s.UpdateName(r.Context(), user, id, name)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -119,6 +125,8 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
|
||||
|
||||
func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -127,7 +135,7 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
|
||||
|
||||
id := r.PathValue("id")
|
||||
|
||||
err := h.s.Delete(user, id)
|
||||
err := h.s.Delete(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -1,16 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/service"
|
||||
"spend-sparrow/template/auth"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/utils"
|
||||
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"spend-sparrow/internal/handler/middleware"
|
||||
"spend-sparrow/internal/service"
|
||||
"spend-sparrow/internal/template/auth"
|
||||
"spend-sparrow/internal/types"
|
||||
"spend-sparrow/internal/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,6 +58,8 @@ var (
|
||||
|
||||
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user != nil {
|
||||
if !user.EmailVerified {
|
||||
@@ -77,12 +78,14 @@ func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
|
||||
session := middleware.GetSession(r)
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
|
||||
session, user, err := handler.service.SignIn(session, email, password)
|
||||
session, user, err := handler.service.SignIn(r.Context(), session, email, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,9 +98,9 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
||||
} else {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -112,6 +115,8 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
|
||||
if user != nil {
|
||||
@@ -130,6 +135,8 @@ func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -148,26 +155,30 @@ func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
go handler.service.SendVerificationMail(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>"))
|
||||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
|
||||
err := handler.service.VerifyUserEmail(token)
|
||||
err := handler.service.VerifyUserEmail(r.Context(), token)
|
||||
|
||||
isVerified := err == nil
|
||||
comp := auth.VerifyResponseComp(isVerified)
|
||||
@@ -185,46 +196,50 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
var email = r.FormValue("email")
|
||||
var password = r.FormValue("password")
|
||||
|
||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
|
||||
log.Info("Signing up %v", email)
|
||||
user, err := handler.service.SignUp(email, password)
|
||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||
slog.InfoContext(r.Context(), "signing up", "email", email)
|
||||
user, err := handler.service.SignUp(r.Context(), email, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info("Sending verification email to %v", user.Email)
|
||||
go handler.service.SendVerificationMail(user.Id, user.Email)
|
||||
slog.InfoContext(r.Context(), "Sending verification email", "to", user.Email)
|
||||
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, types.ErrInternal):
|
||||
utils.TriggerToastWithStatus(w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||
return
|
||||
case errors.Is(err, service.ErrInvalidEmail):
|
||||
utils.TriggerToastWithStatus(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
|
||||
case errors.Is(err, service.ErrInvalidPassword):
|
||||
utils.TriggerToastWithStatus(w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// If err is "service.ErrAccountExists", then just continue
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
session := middleware.GetSession(r)
|
||||
|
||||
if session != nil {
|
||||
err := handler.service.SignOut(session.Id)
|
||||
err := handler.service.SignOut(r.Context(), session.Id)
|
||||
if err != nil {
|
||||
http.Error(w, "An error occurred", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -248,6 +263,8 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -261,6 +278,8 @@ func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -269,12 +288,12 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||
|
||||
password := r.FormValue("password")
|
||||
|
||||
err := handler.service.DeleteAccount(user, password)
|
||||
err := handler.service.DeleteAccount(r.Context(), user, password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Password not correct", http.StatusBadRequest)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
|
||||
} else {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -285,6 +304,8 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
isPasswordReset := r.URL.Query().Has("token")
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
@@ -301,28 +322,32 @@ func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
session := middleware.GetSession(r)
|
||||
user := middleware.GetUser(r)
|
||||
if session == nil || user == nil {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Unathorized", http.StatusUnauthorized)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
currPass := r.FormValue("current-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 {
|
||||
utils.TriggerToastWithStatus(w, r, "error", err.Error(), http.StatusBadRequest)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(w, r, "success", "Password changed", http.StatusOK)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user != nil {
|
||||
utils.DoRedirect(w, r, "/")
|
||||
@@ -336,42 +361,46 @@ func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
|
||||
|
||||
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
email := r.FormValue("email")
|
||||
if email == "" {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Please enter an email", http.StatusBadRequest)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
|
||||
err := handler.service.SendForgotPasswordMail(email)
|
||||
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
|
||||
err := handler.service.SendForgotPasswordMail(r.Context(), email)
|
||||
return nil, err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
} else {
|
||||
utils.TriggerToastWithStatus(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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
|
||||
if err != nil {
|
||||
log.Error("Could not get current URL: %v", err)
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token := pageUrl.Query().Get("token")
|
||||
newPass := r.FormValue("new-password")
|
||||
|
||||
err = handler.service.ForgotPassword(token, newPass)
|
||||
err = handler.service.ForgotPassword(r.Context(), token, newPass)
|
||||
if err != nil {
|
||||
utils.TriggerToastWithStatus(w, r, "error", err.Error(), http.StatusBadRequest)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
utils.TriggerToastWithStatus(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 (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"spend-sparrow/service"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/internal/service"
|
||||
"spend-sparrow/internal/types"
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
@@ -16,14 +17,21 @@ var UserKey ContextKey = "user"
|
||||
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if strings.Contains(r.URL.Path, "/static/") {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
sessionId := getSessionID(r)
|
||||
session, user, _ := service.SignInSession(sessionId)
|
||||
session, user, _ := service.SignInSession(r.Context(), sessionId)
|
||||
|
||||
var err error
|
||||
// Always sign in anonymous
|
||||
// This way, we can always generate csrf tokens
|
||||
if session == nil {
|
||||
session, err = service.SignInAnonymous()
|
||||
session, err = service.SignInAnonymous(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -33,7 +41,6 @@ func Authenticate(service service.Auth) func(http.Handler) http.Handler {
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserKey, user)
|
||||
ctx = context.WithValue(ctx, SessionKey, session)
|
||||
|
||||
@@ -3,10 +3,15 @@ package middleware
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
)
|
||||
|
||||
func CacheControl(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
counter, _ := otel.Meter("").Int64Counter("spend.sparrow.test")
|
||||
counter.Add(r.Context(), 1)
|
||||
|
||||
shouldCache := strings.HasPrefix(r.URL.Path, "/static")
|
||||
|
||||
if !shouldCache {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"spend-sparrow/log"
|
||||
)
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
@@ -34,7 +33,7 @@ func Gzip(next http.Handler) http.Handler {
|
||||
|
||||
err := gz.Close()
|
||||
if err != nil && !errors.Is(err, http.ErrBodyNotAllowed) {
|
||||
log.Error("Gzip: could not close Writer: %v", err)
|
||||
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,8 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/internal/types"
|
||||
)
|
||||
|
||||
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
|
||||
@@ -2,7 +2,7 @@ package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Chain list of handlers together.
|
||||
// Wrapper wraps a list of handlers together.
|
||||
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastHandler := next
|
||||
@@ -1,12 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/template"
|
||||
"spend-sparrow/template/auth"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"spend-sparrow/internal/template"
|
||||
"spend-sparrow/internal/template/auth"
|
||||
"spend-sparrow/internal/types"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
@@ -23,7 +22,7 @@ func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, c
|
||||
w.WriteHeader(status)
|
||||
err := comp.Render(r.Context(), w)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/template"
|
||||
"spend-sparrow/internal/handler/middleware"
|
||||
"spend-sparrow/internal/service"
|
||||
"spend-sparrow/internal/template"
|
||||
"spend-sparrow/internal/utils"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
@@ -13,12 +15,14 @@ type Index interface {
|
||||
}
|
||||
|
||||
type IndexImpl struct {
|
||||
render *Render
|
||||
r *Render
|
||||
c service.Clock
|
||||
}
|
||||
|
||||
func NewIndex(render *Render) Index {
|
||||
func NewIndex(r *Render, c service.Clock) Index {
|
||||
return IndexImpl{
|
||||
render: render,
|
||||
r: r,
|
||||
c: c,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +33,12 @@ func (handler IndexImpl) Handle(router *http.ServeMux) {
|
||||
|
||||
func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
|
||||
htmx := utils.IsHtmx(r)
|
||||
|
||||
var comp templ.Component
|
||||
|
||||
var status int
|
||||
@@ -39,19 +47,26 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
||||
status = http.StatusNotFound
|
||||
} else {
|
||||
if user != nil {
|
||||
comp = template.Dashboard()
|
||||
utils.DoRedirect(w, r, "/dashboard")
|
||||
return
|
||||
} else {
|
||||
comp = template.Index()
|
||||
}
|
||||
status = http.StatusOK
|
||||
}
|
||||
|
||||
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/service"
|
||||
t "spend-sparrow/template/transaction"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/utils"
|
||||
"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"
|
||||
|
||||
@@ -45,6 +46,8 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
|
||||
|
||||
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -55,21 +58,22 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
||||
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(user, filter)
|
||||
transactions, err := h.s.GetAll(r.Context(), user, filter)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := h.account.GetAll(user)
|
||||
accounts, err := h.account.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
treasureChests, err := h.treasureChest.GetAll(user)
|
||||
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -89,19 +93,21 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
|
||||
|
||||
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(user)
|
||||
accounts, err := h.account.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
treasureChests, err := h.treasureChest.GetAll(user)
|
||||
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -114,7 +120,7 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
transaction, err := h.s.Get(user, id)
|
||||
transaction, err := h.s.Get(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -133,6 +139,8 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
|
||||
|
||||
func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
@@ -180,7 +188,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
handleError(w, r, fmt.Errorf("could not parse value: %w", service.ErrBadRequest))
|
||||
return
|
||||
}
|
||||
value := int64(valueF * service.DECIMALS_MULTIPLIER)
|
||||
value := int64(math.Round(valueF * service.DECIMALS_MULTIPLIER))
|
||||
|
||||
timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp"))
|
||||
if err != nil {
|
||||
@@ -200,26 +208,26 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
|
||||
var transaction *types.Transaction
|
||||
if idStr == "new" {
|
||||
transaction, err = h.s.Add(user, input)
|
||||
transaction, err = h.s.Add(r.Context(), nil, user, input)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
transaction, err = h.s.Update(user, input)
|
||||
transaction, err = h.s.Update(r.Context(), user, input)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accounts, err := h.account.GetAll(user)
|
||||
accounts, err := h.account.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
treasureChests, err := h.treasureChest.GetAll(user)
|
||||
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -233,24 +241,28 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
|
||||
|
||||
func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updateSpan(r)
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
utils.DoRedirect(w, r, "/auth/signin")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.s.RecalculateBalances(user)
|
||||
err := h.s.RecalculateBalances(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.TriggerToastWithStatus(w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
|
||||
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")
|
||||
@@ -259,7 +271,7 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
|
||||
|
||||
id := r.PathValue("id")
|
||||
|
||||
err := h.s.Delete(user, id)
|
||||
err := h.s.Delete(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -2,11 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/service"
|
||||
t "spend-sparrow/template/transaction_recurring"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/utils"
|
||||
"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 {
|
||||
@@ -33,6 +33,8 @@ func (h TransactionRecurringImpl) Handle(r *http.ServeMux) {
|
||||
|
||||
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")
|
||||
@@ -48,6 +50,8 @@ func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.Hand
|
||||
|
||||
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")
|
||||
@@ -66,13 +70,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
|
||||
}
|
||||
|
||||
if input.Id == "new" {
|
||||
_, err := h.s.Add(user, input)
|
||||
_, err := h.s.Add(r.Context(), user, input)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err := h.s.Update(user, input)
|
||||
_, err := h.s.Update(r.Context(), user, input)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -85,6 +89,8 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
|
||||
|
||||
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")
|
||||
@@ -95,7 +101,7 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
|
||||
accountId := r.URL.Query().Get("account-id")
|
||||
treasureChestId := r.URL.Query().Get("treasure-chest-id")
|
||||
|
||||
err := h.s.Delete(user, id)
|
||||
err := h.s.Delete(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -109,16 +115,16 @@ func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Req
|
||||
var transactionsRecurring []*types.TransactionRecurring
|
||||
var err error
|
||||
if accountId == "" && treasureChestId == "" {
|
||||
utils.TriggerToastWithStatus(w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
|
||||
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
|
||||
}
|
||||
if accountId != "" {
|
||||
transactionsRecurring, err = h.s.GetAllByAccount(user, accountId)
|
||||
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
transactionsRecurring, err = h.s.GetAllByTreasureChest(user, treasureChestId)
|
||||
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -2,12 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/service"
|
||||
tr "spend-sparrow/template/transaction_recurring"
|
||||
t "spend-sparrow/template/treasurechest"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/utils"
|
||||
"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"
|
||||
@@ -40,19 +40,21 @@ func (h TreasureChestImpl) Handle(r *http.ServeMux) {
|
||||
|
||||
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(user)
|
||||
treasureChests, err := h.s.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
transactionsRecurring, err := h.transactionRecurring.GetAll(user)
|
||||
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -67,13 +69,15 @@ func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
|
||||
|
||||
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(user)
|
||||
treasureChests, err := h.s.GetAll(r.Context(), user)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -86,13 +90,13 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
treasureChest, err := h.s.Get(user, id)
|
||||
treasureChest, err := h.s.Get(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(user, treasureChest.Id.String())
|
||||
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -112,6 +116,8 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
|
||||
|
||||
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")
|
||||
@@ -126,20 +132,20 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
|
||||
parentId := r.FormValue("parent-id")
|
||||
name := r.FormValue("name")
|
||||
if id == "new" {
|
||||
treasureChest, err = h.s.Add(user, parentId, name)
|
||||
treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
treasureChest, err = h.s.Update(user, id, parentId, name)
|
||||
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(user, treasureChest.Id.String())
|
||||
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
@@ -155,6 +161,8 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
|
||||
|
||||
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")
|
||||
@@ -163,7 +171,7 @@ func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
|
||||
|
||||
id := r.PathValue("id")
|
||||
|
||||
err := h.s.Delete(user, id)
|
||||
err := h.s.Delete(r.Context(), user, id)
|
||||
if err != nil {
|
||||
handleError(w, r, err)
|
||||
return
|
||||
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
|
||||
}
|
||||
@@ -1,35 +1,23 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
accountMetric = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "spendsparrow_account_total",
|
||||
Help: "The total of account operations",
|
||||
},
|
||||
[]string{"operation"},
|
||||
)
|
||||
)
|
||||
|
||||
type Account interface {
|
||||
Add(user *types.User, name string) (*types.Account, error)
|
||||
UpdateName(user *types.User, id string, name string) (*types.Account, error)
|
||||
Get(user *types.User, id string) (*types.Account, error)
|
||||
GetAll(user *types.User) ([]*types.Account, error)
|
||||
Delete(user *types.User, id string) error
|
||||
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 {
|
||||
@@ -46,14 +34,12 @@ func NewAccount(db *sqlx.DB, random Random, clock Clock) Account {
|
||||
}
|
||||
}
|
||||
|
||||
func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error) {
|
||||
accountMetric.WithLabelValues("add").Inc()
|
||||
|
||||
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()
|
||||
newId, err := s.random.UUID(ctx)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
@@ -79,10 +65,10 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
|
||||
UpdatedBy: nil,
|
||||
}
|
||||
|
||||
r, err := s.db.NamedExec(`
|
||||
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("account Insert", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "account Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,8 +76,7 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*types.Account, error) {
|
||||
accountMetric.WithLabelValues("update").Inc()
|
||||
func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error) {
|
||||
if user == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
@@ -101,12 +86,12 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
|
||||
}
|
||||
uuid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
log.Error("account update: %v", err)
|
||||
slog.ErrorContext(ctx, "account update", "err", err)
|
||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("account Update", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,8 +100,8 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
|
||||
}()
|
||||
|
||||
var account types.Account
|
||||
err = tx.Get(&account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("account Update", nil, err)
|
||||
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)
|
||||
@@ -129,7 +114,7 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
|
||||
account.UpdatedAt = ×tamp
|
||||
account.UpdatedBy = &user.Id
|
||||
|
||||
r, err := tx.NamedExec(`
|
||||
r, err := tx.NamedExecContext(ctx, `
|
||||
UPDATE account
|
||||
SET
|
||||
name = :name,
|
||||
@@ -137,13 +122,13 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
AND user_id = :user_id`, account)
|
||||
err = db.TransformAndLogDbError("account Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "account Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("account Update", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,40 +136,37 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s AccountImpl) Get(user *types.User, id string) (*types.Account, error) {
|
||||
accountMetric.WithLabelValues("get").Inc()
|
||||
|
||||
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 {
|
||||
log.Error("account get: %v", err)
|
||||
slog.ErrorContext(ctx, "account get", "err", err)
|
||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
var account types.Account
|
||||
err = s.db.Get(&account, `
|
||||
err = s.db.GetContext(ctx, &account, `
|
||||
SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("account Get", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "account Get", nil, err)
|
||||
if err != nil {
|
||||
log.Error("account get: %v", err)
|
||||
slog.ErrorContext(ctx, "account get", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s AccountImpl) GetAll(user *types.User) ([]*types.Account, error) {
|
||||
accountMetric.WithLabelValues("get_all").Inc()
|
||||
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.Select(&accounts, `
|
||||
err := s.db.SelectContext(ctx, &accounts, `
|
||||
SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id)
|
||||
err = db.TransformAndLogDbError("account GetAll", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "account GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,19 +174,18 @@ func (s AccountImpl) GetAll(user *types.User) ([]*types.Account, error) {
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (s AccountImpl) Delete(user *types.User, id string) error {
|
||||
accountMetric.WithLabelValues("delete").Inc()
|
||||
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 {
|
||||
log.Error("account delete: %v", err)
|
||||
slog.ErrorContext(ctx, "account delete", "err", err)
|
||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("account Delete", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -213,8 +194,8 @@ func (s AccountImpl) Delete(user *types.User, id string) error {
|
||||
}()
|
||||
|
||||
transactionsCount := 0
|
||||
err = tx.Get(&transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("account Delete", nil, err)
|
||||
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
|
||||
}
|
||||
@@ -222,14 +203,14 @@ func (s AccountImpl) Delete(user *types.User, id string) error {
|
||||
return fmt.Errorf("account has transactions, cannot delete: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
res, err := tx.Exec("DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
|
||||
err = db.TransformAndLogDbError("account Delete", res, err)
|
||||
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("account Delete", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4,15 +4,14 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
"spend-sparrow/internal/db"
|
||||
mailTemplate "spend-sparrow/internal/template/mail"
|
||||
"spend-sparrow/internal/types"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/log"
|
||||
mailTemplate "spend-sparrow/template/mail"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -27,24 +26,26 @@ var (
|
||||
)
|
||||
|
||||
type Auth interface {
|
||||
SignUp(email string, password string) (*types.User, error)
|
||||
SendVerificationMail(userId uuid.UUID, email string)
|
||||
VerifyUserEmail(token string) error
|
||||
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(session *types.Session, email string, password string) (*types.Session, *types.User, error)
|
||||
SignInSession(sessionId string) (*types.Session, *types.User, error)
|
||||
SignInAnonymous() (*types.Session, error)
|
||||
SignOut(sessionId string) error
|
||||
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(user *types.User, currPass string) error
|
||||
DeleteAccount(ctx context.Context, user *types.User, currPass string) error
|
||||
|
||||
ChangePassword(user *types.User, sessionId string, currPass, newPass string) error
|
||||
ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error
|
||||
|
||||
SendForgotPasswordMail(email string) error
|
||||
ForgotPassword(token string, newPass string) error
|
||||
SendForgotPasswordMail(ctx context.Context, email string) error
|
||||
ForgotPassword(ctx context.Context, token string, newPass string) error
|
||||
|
||||
IsCsrfTokenValid(tokenStr string, sessionId string) bool
|
||||
GetCsrfToken(session *types.Session) (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 {
|
||||
@@ -65,8 +66,8 @@ func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *
|
||||
}
|
||||
}
|
||||
|
||||
func (service AuthImpl) SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error) {
|
||||
user, err := service.db.GetUserByEmail(email)
|
||||
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
|
||||
@@ -81,30 +82,41 @@ func (service AuthImpl) SignIn(session *types.Session, email string, password st
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
err = service.cleanUpSessionWithTokens(session)
|
||||
newSession, err := service.createSession(ctx, user.Id)
|
||||
if err != nil {
|
||||
return nil, nil, types.ErrInternal
|
||||
}
|
||||
|
||||
session, err = service.createSession(user.Id)
|
||||
err = service.db.DeleteSession(ctx, session.Id)
|
||||
if err != nil {
|
||||
return nil, nil, types.ErrInternal
|
||||
}
|
||||
|
||||
return session, user, nil
|
||||
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(sessionId string) (*types.Session, *types.User, error) {
|
||||
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(sessionId)
|
||||
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(sessionId)
|
||||
_ = service.db.DeleteSession(ctx, sessionId)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -112,7 +124,7 @@ func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.
|
||||
return session, nil, nil
|
||||
}
|
||||
|
||||
user, err := service.db.GetUser(session.UserId)
|
||||
user, err := service.db.GetUser(ctx, session.UserId)
|
||||
if err != nil {
|
||||
return nil, nil, types.ErrInternal
|
||||
}
|
||||
@@ -120,18 +132,18 @@ func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.
|
||||
return session, user, nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) SignInAnonymous() (*types.Session, error) {
|
||||
session, err := service.createSession(uuid.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
|
||||
}
|
||||
|
||||
log.Info("Anonymous session created: %v", session.Id)
|
||||
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) SignUp(email string, password string) (*types.User, error) {
|
||||
func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) {
|
||||
_, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidEmail
|
||||
@@ -141,12 +153,12 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
|
||||
return nil, ErrInvalidPassword
|
||||
}
|
||||
|
||||
userId, err := service.random.UUID()
|
||||
userId, err := service.random.UUID(ctx)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
salt, err := service.random.Bytes(16)
|
||||
salt, err := service.random.Bytes(ctx, 16)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
@@ -155,7 +167,7 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
|
||||
|
||||
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
|
||||
|
||||
err = service.db.InsertUser(user)
|
||||
err = service.db.InsertUser(ctx, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrAlreadyExists) {
|
||||
return nil, ErrAccountExists
|
||||
@@ -167,8 +179,8 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
|
||||
tokens, err := service.db.GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify)
|
||||
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
|
||||
}
|
||||
@@ -180,7 +192,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
newTokenStr, err := service.random.String(32)
|
||||
newTokenStr, err := service.random.String(ctx, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -193,7 +205,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
|
||||
service.clock.Now(),
|
||||
service.clock.Now().Add(24*time.Hour))
|
||||
|
||||
err = service.db.InsertToken(token)
|
||||
err = service.db.InsertToken(ctx, token)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -202,24 +214,24 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
|
||||
var w strings.Builder
|
||||
err = mailTemplate.Register(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &w)
|
||||
if err != nil {
|
||||
log.Error("Could not render welcome email: %v", err)
|
||||
slog.ErrorContext(ctx, "Could not render welcome email", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.mail.SendMail(email, "Welcome to spend-sparrow", w.String())
|
||||
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
|
||||
}
|
||||
|
||||
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
|
||||
func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
|
||||
if tokenStr == "" {
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
token, err := service.db.GetToken(tokenStr)
|
||||
token, err := service.db.GetToken(ctx, tokenStr)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
user, err := service.db.GetUser(token.UserId)
|
||||
user, err := service.db.GetUser(ctx, token.UserId)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
@@ -237,21 +249,21 @@ func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
|
||||
user.EmailVerified = true
|
||||
user.EmailVerifiedAt = &now
|
||||
|
||||
err = service.db.UpdateUser(user)
|
||||
err = service.db.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
_ = service.db.DeleteToken(token.Token)
|
||||
_ = service.db.DeleteToken(ctx, token.Token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) SignOut(sessionId string) error {
|
||||
return service.db.DeleteSession(sessionId)
|
||||
func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error {
|
||||
return service.db.DeleteSession(ctx, sessionId)
|
||||
}
|
||||
|
||||
func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
|
||||
userDb, err := service.db.GetUser(user.Id)
|
||||
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
|
||||
}
|
||||
@@ -261,17 +273,17 @@ func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
err = service.db.DeleteUser(user.Id)
|
||||
err = service.db.DeleteUser(ctx, user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.mail.SendMail(user.Email, "Account deleted", "Your account has been deleted")
|
||||
service.mail.SendMail(ctx, user.Email, "Account deleted", "Your account has been deleted")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currPass, newPass string) error {
|
||||
func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error {
|
||||
if !isPasswordValid(newPass) {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
@@ -289,18 +301,18 @@ func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currP
|
||||
newHash := GetHashPassword(newPass, user.Salt)
|
||||
user.Password = newHash
|
||||
|
||||
err := service.db.UpdateUser(user)
|
||||
err := service.db.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := service.db.GetSessions(user.Id)
|
||||
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(s.Id)
|
||||
err = service.db.DeleteSession(ctx, s.Id)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
@@ -310,13 +322,13 @@ func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currP
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) SendForgotPasswordMail(email string) error {
|
||||
tokenStr, err := service.random.String(32)
|
||||
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(email)
|
||||
user, err := service.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil
|
||||
@@ -333,7 +345,7 @@ func (service AuthImpl) SendForgotPasswordMail(email string) error {
|
||||
service.clock.Now(),
|
||||
service.clock.Now().Add(15*time.Minute))
|
||||
|
||||
err = service.db.InsertToken(token)
|
||||
err = service.db.InsertToken(ctx, token)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
@@ -341,25 +353,25 @@ func (service AuthImpl) SendForgotPasswordMail(email string) error {
|
||||
var mail strings.Builder
|
||||
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
|
||||
if err != nil {
|
||||
log.Error("Could not render reset password email: %v", err)
|
||||
slog.ErrorContext(ctx, "Could not render reset password email", "err", err)
|
||||
return types.ErrInternal
|
||||
}
|
||||
service.mail.SendMail(email, "Reset Password", mail.String())
|
||||
service.mail.SendMail(ctx, email, "Reset Password", mail.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
|
||||
func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
|
||||
if !isPasswordValid(newPass) {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
|
||||
token, err := service.db.GetToken(tokenStr)
|
||||
token, err := service.db.GetToken(ctx, tokenStr)
|
||||
if err != nil {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
err = service.db.DeleteToken(tokenStr)
|
||||
err = service.db.DeleteToken(ctx, tokenStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -369,27 +381,27 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
user, err := service.db.GetUser(token.UserId)
|
||||
user, err := service.db.GetUser(ctx, token.UserId)
|
||||
if err != nil {
|
||||
log.Error("Could not get user from token: %v", err)
|
||||
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(user)
|
||||
err = service.db.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := service.db.GetSessions(user.Id)
|
||||
sessions, err := service.db.GetSessions(ctx, user.Id)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
err = service.db.DeleteSession(session.Id)
|
||||
err = service.db.DeleteSession(ctx, session.Id)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
@@ -398,8 +410,8 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool {
|
||||
token, err := service.db.GetToken(tokenStr)
|
||||
func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
|
||||
token, err := service.db.GetToken(ctx, tokenStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -413,18 +425,18 @@ func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
|
||||
func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session) (string, error) {
|
||||
if session == nil {
|
||||
return "", types.ErrInternal
|
||||
}
|
||||
|
||||
tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
|
||||
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
|
||||
|
||||
if len(tokens) > 0 {
|
||||
return tokens[0].Token, nil
|
||||
}
|
||||
|
||||
tokenStr, err := service.random.String(32)
|
||||
tokenStr, err := service.random.String(ctx, 32)
|
||||
if err != nil {
|
||||
return "", types.ErrInternal
|
||||
}
|
||||
@@ -436,47 +448,32 @@ func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
|
||||
types.TokenTypeCsrf,
|
||||
service.clock.Now(),
|
||||
service.clock.Now().Add(8*time.Hour))
|
||||
err = service.db.InsertToken(token)
|
||||
err = service.db.InsertToken(ctx, token)
|
||||
if err != nil {
|
||||
return "", types.ErrInternal
|
||||
}
|
||||
|
||||
log.Info("CSRF-Token created: %v", tokenStr)
|
||||
slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr)
|
||||
|
||||
return tokenStr, nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) cleanUpSessionWithTokens(session *types.Session) error {
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := service.db.DeleteSession(session.Id)
|
||||
func (service AuthImpl) CleanupSessionsAndTokens(ctx context.Context) error {
|
||||
err := service.db.DeleteOldSessions(ctx)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
|
||||
tokens, err := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
|
||||
err = service.db.DeleteOldTokens(ctx)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
for _, token := range tokens {
|
||||
err = service.db.DeleteToken(token.Token)
|
||||
if err != nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error) {
|
||||
sessionId, err := service.random.String(32)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
err = service.db.DeleteOldSessions(userId)
|
||||
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
|
||||
}
|
||||
@@ -486,7 +483,7 @@ func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error)
|
||||
|
||||
session := types.NewSession(sessionId, userId, createAt, expiresAt)
|
||||
|
||||
err = service.db.InsertSession(session)
|
||||
err = service.db.InsertSession(ctx, session)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
@@ -13,5 +13,5 @@ func NewClock() Clock {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'" -]+$`)
|
||||
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`)
|
||||
)
|
||||
|
||||
func validateString(value string, fieldName string) error {
|
||||
@@ -1,16 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"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(to string, subject string, message string)
|
||||
SendMail(ctx context.Context, to string, subject string, message string)
|
||||
}
|
||||
|
||||
type MailImpl struct {
|
||||
@@ -21,11 +21,11 @@ func NewMail(server *types.Settings) MailImpl {
|
||||
return MailImpl{server: server}
|
||||
}
|
||||
|
||||
func (m MailImpl) SendMail(to string, subject string, message string) {
|
||||
go m.internalSendMail(to, subject, message)
|
||||
func (m MailImpl) SendMail(ctx context.Context, to string, subject string, message string) {
|
||||
go m.internalSendMail(ctx, to, subject, message)
|
||||
}
|
||||
|
||||
func (m MailImpl) internalSendMail(to string, subject string, message string) {
|
||||
func (m MailImpl) internalSendMail(ctx context.Context, to string, subject string, message string) {
|
||||
if m.server.Smtp == nil {
|
||||
return
|
||||
}
|
||||
@@ -48,9 +48,9 @@ func (m MailImpl) internalSendMail(to string, subject string, message string) {
|
||||
subject,
|
||||
message)
|
||||
|
||||
log.Info("Sending mail to %v", to)
|
||||
slog.InfoContext(ctx, "sending mail", "to", to)
|
||||
err := smtp.SendMail(s.Host+":"+s.Port, auth, s.FromMail, []string{to}, []byte(msg))
|
||||
if err != nil {
|
||||
log.Error("Error sending mail: %v", err)
|
||||
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
|
||||
}
|
||||
@@ -1,37 +1,29 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
"log/slog"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
transactionMetric = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "spendsparrow_transaction_total",
|
||||
Help: "The total of transaction operations",
|
||||
},
|
||||
[]string{"operation"},
|
||||
)
|
||||
)
|
||||
const page_size = 25
|
||||
|
||||
type Transaction interface {
|
||||
Add(user *types.User, transaction types.Transaction) (*types.Transaction, error)
|
||||
Update(user *types.User, transaction types.Transaction) (*types.Transaction, error)
|
||||
Get(user *types.User, id string) (*types.Transaction, error)
|
||||
GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
|
||||
Delete(user *types.User, id string) error
|
||||
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(user *types.User) error
|
||||
RecalculateBalances(ctx context.Context, user *types.User) error
|
||||
}
|
||||
|
||||
type TransactionImpl struct {
|
||||
@@ -48,76 +40,80 @@ func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction {
|
||||
}
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Add(user *types.User, transactionInput types.Transaction) (*types.Transaction, error) {
|
||||
transactionMetric.WithLabelValues("add").Inc()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transaction Add", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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()
|
||||
}()
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
transaction, err := s.validateAndEnrichTransaction(tx, nil, user.Id, transactionInput)
|
||||
transaction, err := s.validateAndEnrichTransaction(ctx, tx, nil, user.Id, transactionInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := tx.NamedExec(`
|
||||
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("transaction Insert", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if transaction.Error == nil && transaction.AccountId != nil {
|
||||
r, err = tx.Exec(`
|
||||
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("transaction Add", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if transaction.Error == nil && transaction.TreasureChestId != nil {
|
||||
r, err = tx.Exec(`
|
||||
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("transaction Add", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transaction Add", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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(user *types.User, input types.Transaction) (*types.Transaction, error) {
|
||||
transactionMetric.WithLabelValues("update").Inc()
|
||||
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.Beginx()
|
||||
err = db.TransformAndLogDbError("transaction Update", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -126,8 +122,8 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
|
||||
}()
|
||||
|
||||
transaction := &types.Transaction{}
|
||||
err = tx.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
|
||||
err = db.TransformAndLogDbError("transaction Update", nil, err)
|
||||
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)
|
||||
@@ -136,53 +132,53 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
|
||||
}
|
||||
|
||||
if transaction.Error == nil && transaction.AccountId != nil {
|
||||
r, err := tx.Exec(`
|
||||
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("transaction Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if transaction.Error == nil && transaction.TreasureChestId != nil {
|
||||
r, err := tx.Exec(`
|
||||
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("transaction Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
transaction, err = s.validateAndEnrichTransaction(tx, transaction, user.Id, input)
|
||||
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.Exec(`
|
||||
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("transaction Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if transaction.Error == nil && transaction.TreasureChestId != nil {
|
||||
r, err := tx.Exec(`
|
||||
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("transaction Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
r, err := tx.NamedExec(`
|
||||
r, err := tx.NamedExecContext(ctx, `
|
||||
UPDATE "transaction"
|
||||
SET
|
||||
account_id = :account_id,
|
||||
@@ -196,13 +192,13 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
AND user_id = :user_id`, transaction)
|
||||
err = db.TransformAndLogDbError("transaction Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transaction Update", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,21 +206,19 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, error) {
|
||||
transactionMetric.WithLabelValues("get").Inc()
|
||||
|
||||
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 {
|
||||
log.Error("transaction get: %v", err)
|
||||
slog.ErrorContext(ctx, "transaction get", "err", err)
|
||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
var transaction types.Transaction
|
||||
err = s.db.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("transaction Get", nil, err)
|
||||
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)
|
||||
@@ -235,29 +229,47 @@ func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, e
|
||||
return &transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) {
|
||||
transactionMetric.WithLabelValues("get_all").Inc()
|
||||
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.Select(&transactions, `
|
||||
err = s.db.SelectContext(ctx, &transactions, `
|
||||
SELECT *
|
||||
FROM "transaction"
|
||||
WHERE user_id = ?
|
||||
AND (? = '' OR account_id = ?)
|
||||
AND (? = '' OR treasure_chest_id = ?)
|
||||
AND (? = ''
|
||||
OR (? = "true" AND error IS NOT NULL)
|
||||
OR (? = "false" AND error IS NULL)
|
||||
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`,
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
`,
|
||||
user.Id,
|
||||
filter.AccountId, filter.AccountId,
|
||||
filter.TreasureChestId, filter.TreasureChestId,
|
||||
filter.Error, filter.Error, filter.Error)
|
||||
err = db.TransformAndLogDbError("transaction GetAll", nil, err)
|
||||
filter.AccountId,
|
||||
filter.TreasureChestId,
|
||||
filter.Error,
|
||||
page_size,
|
||||
offset)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,19 +277,18 @@ func (s TransactionImpl) GetAll(user *types.User, filter types.TransactionItemsF
|
||||
return transactions, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) Delete(user *types.User, id string) error {
|
||||
transactionMetric.WithLabelValues("delete").Inc()
|
||||
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 {
|
||||
log.Error("transaction delete: %v", err)
|
||||
slog.ErrorContext(ctx, "transaction delete", "err", err)
|
||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transaction Delete", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -286,44 +297,44 @@ func (s TransactionImpl) Delete(user *types.User, id string) error {
|
||||
}()
|
||||
|
||||
var transaction types.Transaction
|
||||
err = tx.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("transaction Delete", nil, err)
|
||||
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.Exec(`
|
||||
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("transaction Delete", r, err)
|
||||
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.Exec(`
|
||||
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("transaction Delete", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
r, err := tx.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
|
||||
err = db.TransformAndLogDbError("transaction Delete", r, err)
|
||||
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("transaction Delete", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -331,14 +342,13 @@ func (s TransactionImpl) Delete(user *types.User, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) RecalculateBalances(user *types.User) error {
|
||||
transactionMetric.WithLabelValues("recalculate").Inc()
|
||||
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.User) error {
|
||||
if user == nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -346,54 +356,54 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
r, err := tx.Exec(`
|
||||
r, err := tx.ExecContext(ctx, `
|
||||
UPDATE account
|
||||
SET current_balance = 0
|
||||
WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err = tx.Exec(`
|
||||
r, err = tx.ExecContext(ctx, `
|
||||
UPDATE treasure_chest
|
||||
SET current_balance = 0
|
||||
WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := tx.Queryx(`
|
||||
rows, err := tx.QueryxContext(ctx, `
|
||||
SELECT *
|
||||
FROM "transaction"
|
||||
WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
|
||||
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 {
|
||||
log.Error("transaction RecalculateBalances: %v", err)
|
||||
slog.ErrorContext(ctx, "transaction RecalculateBalances", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var transaction types.Transaction
|
||||
for rows.Next() {
|
||||
err = rows.StructScan(&transaction)
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.updateErrors(&transaction)
|
||||
r, err = tx.Exec(`
|
||||
r, err = tx.ExecContext(ctx, `
|
||||
UPDATE "transaction"
|
||||
SET error = ?
|
||||
WHERE user_id = ?
|
||||
AND id = ?`, transaction.Error, user.Id, transaction.Id)
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -403,21 +413,21 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
|
||||
}
|
||||
|
||||
if transaction.AccountId != nil {
|
||||
r, err = tx.Exec(`
|
||||
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("transaction RecalculateBalances", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if transaction.TreasureChestId != nil {
|
||||
r, err = tx.Exec(`
|
||||
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("transaction RecalculateBalances", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -425,7 +435,7 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -433,7 +443,7 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) {
|
||||
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
|
||||
@@ -446,7 +456,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
|
||||
)
|
||||
|
||||
if oldTransaction == nil {
|
||||
id, err = s.random.UUID()
|
||||
id, err = s.random.UUID(ctx)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
@@ -462,21 +472,21 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
|
||||
}
|
||||
|
||||
if input.AccountId != nil {
|
||||
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId)
|
||||
err = db.TransformAndLogDbError("transaction validate", nil, err)
|
||||
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 {
|
||||
log.Error("transaction validate: %v", err)
|
||||
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.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId)
|
||||
err = db.TransformAndLogDbError("transaction validate", nil, err)
|
||||
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)
|
||||
@@ -524,27 +534,20 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
|
||||
return &transaction, nil
|
||||
}
|
||||
|
||||
func (s TransactionImpl) updateErrors(transaction *types.Transaction) {
|
||||
func (s TransactionImpl) updateErrors(t *types.Transaction) {
|
||||
errorStr := ""
|
||||
|
||||
switch {
|
||||
case transaction.Value < 0:
|
||||
if transaction.TreasureChestId == nil {
|
||||
errorStr = "no treasure chest specified"
|
||||
}
|
||||
case transaction.Value > 0:
|
||||
if transaction.AccountId == nil && transaction.TreasureChestId == nil {
|
||||
errorStr = "either an account or a treasure chest needs to be specified"
|
||||
} else if transaction.AccountId != nil && transaction.TreasureChestId != nil {
|
||||
errorStr = "positive amounts can only be applied to either an account or a treasure chest"
|
||||
}
|
||||
default:
|
||||
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 == "" {
|
||||
transaction.Error = nil
|
||||
t.Error = nil
|
||||
} else {
|
||||
transaction.Error = &errorStr
|
||||
t.Error = &errorStr
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,29 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
transactionRecurringMetric = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "spendsparrow_transaction_recurring_total",
|
||||
Help: "The total of transactionRecurring operations",
|
||||
},
|
||||
[]string{"operation"},
|
||||
)
|
||||
)
|
||||
|
||||
type TransactionRecurring interface {
|
||||
Add(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
|
||||
Update(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
|
||||
GetAll(user *types.User) ([]*types.TransactionRecurring, error)
|
||||
GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error)
|
||||
GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
|
||||
Delete(user *types.User, id string) error
|
||||
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(user *types.User) error
|
||||
GenerateTransactions(ctx context.Context) error
|
||||
}
|
||||
|
||||
type TransactionRecurringImpl struct {
|
||||
@@ -53,17 +42,16 @@ func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transactio
|
||||
}
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) Add(
|
||||
func (s TransactionRecurringImpl) Add(ctx context.Context,
|
||||
user *types.User,
|
||||
transactionRecurringInput types.TransactionRecurringInput) (*types.TransactionRecurring, error) {
|
||||
transactionRecurringMetric.WithLabelValues("add").Inc()
|
||||
|
||||
transactionRecurringInput types.TransactionRecurringInput,
|
||||
) (*types.TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transactionRecurring Add", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,24 +59,24 @@ func (s TransactionRecurringImpl) Add(
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
transactionRecurring, err := s.validateAndEnrichTransactionRecurring(tx, nil, user.Id, transactionRecurringInput)
|
||||
transactionRecurring, err := s.validateAndEnrichTransactionRecurring(ctx, tx, nil, user.Id, transactionRecurringInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := tx.NamedExec(`
|
||||
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("transactionRecurring Insert", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transactionRecurring Add", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,21 +84,21 @@ func (s TransactionRecurringImpl) Add(
|
||||
return transactionRecurring, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) Update(
|
||||
func (s TransactionRecurringImpl) Update(ctx context.Context,
|
||||
user *types.User,
|
||||
input types.TransactionRecurringInput) (*types.TransactionRecurring, error) {
|
||||
transactionRecurringMetric.WithLabelValues("update").Inc()
|
||||
input types.TransactionRecurringInput,
|
||||
) (*types.TransactionRecurring, error) {
|
||||
if user == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
uuid, err := uuid.Parse(input.Id)
|
||||
if err != nil {
|
||||
log.Error("transactionRecurring update: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring update", "err", err)
|
||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,8 +107,8 @@ func (s TransactionRecurringImpl) Update(
|
||||
}()
|
||||
|
||||
transactionRecurring := &types.TransactionRecurring{}
|
||||
err = tx.Get(transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
|
||||
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)
|
||||
@@ -128,12 +116,12 @@ func (s TransactionRecurringImpl) Update(
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
|
||||
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(tx, transactionRecurring, user.Id, input)
|
||||
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := tx.NamedExec(`
|
||||
r, err := tx.NamedExecContext(ctx, `
|
||||
UPDATE transaction_recurring
|
||||
SET
|
||||
interval_months = :interval_months,
|
||||
@@ -147,13 +135,13 @@ func (s TransactionRecurringImpl) Update(
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
AND user_id = :user_id`, transactionRecurring)
|
||||
err = db.TransformAndLogDbError("transactionRecurring Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,20 +149,19 @@ func (s TransactionRecurringImpl) Update(
|
||||
return transactionRecurring, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GetAll(user *types.User) ([]*types.TransactionRecurring, error) {
|
||||
transactionRecurringMetric.WithLabelValues("get_all_by_account").Inc()
|
||||
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.Select(&transactionRecurrings, `
|
||||
err := s.db.SelectContext(ctx, &transactionRecurrings, `
|
||||
SELECT *
|
||||
FROM transaction_recurring
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
user.Id)
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -182,20 +169,19 @@ func (s TransactionRecurringImpl) GetAll(user *types.User) ([]*types.Transaction
|
||||
return transactionRecurrings, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error) {
|
||||
transactionRecurringMetric.WithLabelValues("get_all_by_account").Inc()
|
||||
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 {
|
||||
log.Error("transactionRecurring GetAllByAccount: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err)
|
||||
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -204,8 +190,8 @@ func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId st
|
||||
}()
|
||||
|
||||
var rowCount int
|
||||
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id)
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
|
||||
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)
|
||||
@@ -214,20 +200,20 @@ func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId st
|
||||
}
|
||||
|
||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
||||
err = tx.Select(&transactionRecurrings, `
|
||||
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("transactionRecurring GetAll", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -235,20 +221,22 @@ func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId st
|
||||
return transactionRecurrings, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error) {
|
||||
transactionRecurringMetric.WithLabelValues("get_all_by_treasurechest").Inc()
|
||||
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 {
|
||||
log.Error("transactionRecurring GetAllByTreasureChest: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err)
|
||||
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,8 +245,8 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(user *types.User, treasu
|
||||
}()
|
||||
|
||||
var rowCount int
|
||||
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id)
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
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)
|
||||
@@ -267,20 +255,20 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(user *types.User, treasu
|
||||
}
|
||||
|
||||
transactionRecurrings := make([]*types.TransactionRecurring, 0)
|
||||
err = tx.Select(&transactionRecurrings, `
|
||||
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("transactionRecurring GetAll", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -288,19 +276,18 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(user *types.User, treasu
|
||||
return transactionRecurrings, nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
|
||||
transactionRecurringMetric.WithLabelValues("delete").Inc()
|
||||
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 {
|
||||
log.Error("transactionRecurring delete: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring delete", "err", err)
|
||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -309,20 +296,20 @@ func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
|
||||
}()
|
||||
|
||||
var transactionRecurring types.TransactionRecurring
|
||||
err = tx.Get(&transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
|
||||
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.Exec("DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
|
||||
err = db.TransformAndLogDbError("transactionRecurring Delete", r, 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("transactionRecurring Delete", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -330,19 +317,31 @@ func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
|
||||
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 := s.db.Select(&recurringTransactions, `
|
||||
SELECT * FROM transaction_recurring WHERE user_id = ? AND next_execution <= ?`,
|
||||
user.Id, now)
|
||||
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
|
||||
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,
|
||||
@@ -352,28 +351,35 @@ func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
|
||||
Value: transactionRecurring.Value,
|
||||
}
|
||||
|
||||
_, err = s.transaction.Add(user, transaction)
|
||||
_, err = s.transaction.Add(ctx, tx, user, transaction)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
|
||||
r, err := s.db.Exec(`UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
|
||||
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
|
||||
nextExecution, transactionRecurring.Id, user.Id)
|
||||
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", r, err)
|
||||
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) {
|
||||
input types.TransactionRecurringInput,
|
||||
) (*types.TransactionRecurring, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
accountUuid *uuid.UUID
|
||||
@@ -389,7 +395,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
)
|
||||
|
||||
if oldTransactionRecurring == nil {
|
||||
id, err = s.random.UUID()
|
||||
id, err = s.random.UUID(ctx)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
@@ -409,17 +415,17 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
if input.AccountId != "" {
|
||||
temp, err := uuid.Parse(input.AccountId)
|
||||
if err != nil {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
|
||||
}
|
||||
accountUuid = &temp
|
||||
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
|
||||
err = db.TransformAndLogDbError("transactionRecurring validate", nil, err)
|
||||
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 {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
@@ -429,13 +435,13 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
if input.TreasureChestId != "" {
|
||||
temp, err := uuid.Parse(input.TreasureChestId)
|
||||
if err != nil {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
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.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
|
||||
err = db.TransformAndLogDbError("transactionRecurring validate", nil, err)
|
||||
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)
|
||||
@@ -449,20 +455,20 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
}
|
||||
|
||||
if !hasAccount && !hasTreasureChest {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||
return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest)
|
||||
}
|
||||
if hasAccount && hasTreasureChest {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
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 {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest)
|
||||
}
|
||||
valueInt := int64(valueFloat * DECIMALS_MULTIPLIER)
|
||||
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
|
||||
|
||||
if input.Party != "" {
|
||||
err = validateString(input.Party, "party")
|
||||
@@ -478,18 +484,18 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
}
|
||||
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
|
||||
if err != nil {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
|
||||
return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest)
|
||||
}
|
||||
if intervalMonths < 1 {
|
||||
log.Error("transactionRecurring validate: %v", err)
|
||||
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 {
|
||||
log.Error("transaction validate: %v", err)
|
||||
slog.ErrorContext(ctx, "transaction validate", "err", err)
|
||||
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
@@ -509,7 +515,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
|
||||
|
||||
AccountId: accountUuid,
|
||||
TreasureChestId: treasureChestUuid,
|
||||
Value: valueInt,
|
||||
Value: value,
|
||||
|
||||
CreatedAt: createdAt,
|
||||
CreatedBy: createdBy,
|
||||
@@ -1,36 +1,24 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
"spend-sparrow/internal/db"
|
||||
"spend-sparrow/internal/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
treasureChestMetric = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "spendsparrow_treasurechest_total",
|
||||
Help: "The total of treasurechest operations",
|
||||
},
|
||||
[]string{"operation"},
|
||||
)
|
||||
)
|
||||
|
||||
type TreasureChest interface {
|
||||
Add(user *types.User, parentId, name string) (*types.TreasureChest, error)
|
||||
Update(user *types.User, id, parentId, name string) (*types.TreasureChest, error)
|
||||
Get(user *types.User, id string) (*types.TreasureChest, error)
|
||||
GetAll(user *types.User) ([]*types.TreasureChest, error)
|
||||
Delete(user *types.User, id string) error
|
||||
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 {
|
||||
@@ -47,14 +35,12 @@ func NewTreasureChest(db *sqlx.DB, random Random, clock Clock) TreasureChest {
|
||||
}
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.TreasureChest, error) {
|
||||
treasureChestMetric.WithLabelValues("add").Inc()
|
||||
|
||||
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()
|
||||
newId, err := s.random.UUID(ctx)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
@@ -66,7 +52,7 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.
|
||||
|
||||
var parentUuid *uuid.UUID
|
||||
if parentId != "" {
|
||||
parent, err := s.Get(user, parentId)
|
||||
parent, err := s.Get(ctx, user, parentId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,10 +77,10 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.
|
||||
UpdatedBy: nil,
|
||||
}
|
||||
|
||||
r, err := s.db.NamedExec(`
|
||||
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("treasureChest Insert", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,8 +88,7 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.
|
||||
return treasureChest, nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
|
||||
treasureChestMetric.WithLabelValues("update").Inc()
|
||||
func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
|
||||
if user == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
@@ -113,12 +98,12 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
|
||||
}
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
log.Error("treasureChest update: %v", err)
|
||||
slog.ErrorContext(ctx, "treasureChest update", "err", err)
|
||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,8 +112,8 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
|
||||
}()
|
||||
|
||||
treasureChest := &types.TreasureChest{}
|
||||
err = tx.Get(treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
|
||||
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
|
||||
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)
|
||||
@@ -138,13 +123,13 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
|
||||
|
||||
var parentUuid *uuid.UUID
|
||||
if parentId != "" {
|
||||
parent, err := s.Get(user, parentId)
|
||||
parent, err := s.Get(ctx, user, parentId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var childCount int
|
||||
err = tx.Get(&childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
|
||||
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
|
||||
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
|
||||
}
|
||||
@@ -161,7 +146,7 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
|
||||
treasureChest.UpdatedAt = ×tamp
|
||||
treasureChest.UpdatedBy = &user.Id
|
||||
|
||||
r, err := tx.NamedExec(`
|
||||
r, err := tx.NamedExecContext(ctx, `
|
||||
UPDATE treasure_chest
|
||||
SET
|
||||
parent_id = :parent_id,
|
||||
@@ -171,13 +156,13 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
|
||||
updated_by = :updated_by
|
||||
WHERE id = :id
|
||||
AND user_id = :user_id`, treasureChest)
|
||||
err = db.TransformAndLogDbError("treasureChest Update", r, err)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,21 +170,19 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
|
||||
return treasureChest, nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Get(user *types.User, id string) (*types.TreasureChest, error) {
|
||||
treasureChestMetric.WithLabelValues("get").Inc()
|
||||
|
||||
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 {
|
||||
log.Error("treasureChest get: %v", err)
|
||||
slog.ErrorContext(ctx, "treasureChest get", "err", err)
|
||||
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
var treasureChest types.TreasureChest
|
||||
err = s.db.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
|
||||
err = db.TransformAndLogDbError("treasureChest Get", nil, err)
|
||||
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)
|
||||
@@ -210,35 +193,33 @@ func (s TreasureChestImpl) Get(user *types.User, id string) (*types.TreasureChes
|
||||
return &treasureChest, nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) GetAll(user *types.User) ([]*types.TreasureChest, error) {
|
||||
treasureChestMetric.WithLabelValues("get_all").Inc()
|
||||
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.Select(&treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
|
||||
err = db.TransformAndLogDbError("treasureChest GetAll", nil, err)
|
||||
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 sortTree(treasureChests), nil
|
||||
return sortTreasureChests(treasureChests), nil
|
||||
}
|
||||
|
||||
func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
|
||||
treasureChestMetric.WithLabelValues("delete").Inc()
|
||||
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 {
|
||||
log.Error("treasureChest delete: %v", err)
|
||||
slog.ErrorContext(ctx, "treasureChest delete", "err", err)
|
||||
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
tx, err := s.db.Beginx()
|
||||
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -247,8 +228,8 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
|
||||
}()
|
||||
|
||||
childCount := 0
|
||||
err = tx.Get(&childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
|
||||
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
|
||||
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
|
||||
}
|
||||
@@ -258,10 +239,10 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
|
||||
}
|
||||
|
||||
transactionsCount := 0
|
||||
err = tx.Get(&transactionsCount,
|
||||
err = tx.GetContext(ctx, &transactionsCount,
|
||||
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
|
||||
user.Id, id)
|
||||
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -269,14 +250,26 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
|
||||
return fmt.Errorf("treasure chest has transactions: %w", ErrBadRequest)
|
||||
}
|
||||
|
||||
r, err := tx.Exec(`DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
|
||||
err = db.TransformAndLogDbError("treasureChest Delete", r, err)
|
||||
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("treasureChest Delete", nil, err)
|
||||
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -284,7 +277,7 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest {
|
||||
func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
|
||||
var (
|
||||
roots []*types.TreasureChest
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
package account
|
||||
|
||||
import "fmt"
|
||||
import "spend-sparrow/template/svg"
|
||||
import "spend-sparrow/types"
|
||||
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">
|
||||
@@ -67,7 +66,9 @@ templ EditAccount(account *types.Account) {
|
||||
hx-swap="outerHTML"
|
||||
class="button button-neglect px-1 flex items-center gap-2"
|
||||
>
|
||||
@svg.Cancel()
|
||||
<span class="h-4 w-4">
|
||||
@svg.Cancel()
|
||||
</span>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
@@ -81,9 +82,9 @@ templ AccountItem(account *types.Account) {
|
||||
<div class="text-xl flex justify-end gap-4">
|
||||
<p class="mr-auto">{ account.Name }</p>
|
||||
if account.CurrentBalance < 0 {
|
||||
<p class="mr-20 text-red-700">{ displayBalance(account.CurrentBalance) }</p>
|
||||
<p class="mr-20 text-red-700">{ types.FormatEuros(account.CurrentBalance) }</p>
|
||||
} else {
|
||||
<p class="mr-20 text-green-700">{ displayBalance(account.CurrentBalance) }</p>
|
||||
<p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p>
|
||||
}
|
||||
<a
|
||||
href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
|
||||
@@ -121,9 +122,3 @@ templ AccountItem(account *types.Account) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func displayBalance(balance int64) string {
|
||||
|
||||
euros := float64(balance) / 100
|
||||
return fmt.Sprintf("%.2f €", euros)
|
||||
}
|
||||
@@ -71,10 +71,10 @@ if isSignIn {
|
||||
Don't have an account?
|
||||
Sign Up
|
||||
</a>
|
||||
<button class="button button-primary font-pirata text-gray-600 text-2xl px-1">Sign In</button>
|
||||
<button class="button button-primary text-gray-600 text-2xl px-1">Sign In</button>
|
||||
} else {
|
||||
<a href="/auth/signin" class="text-gray-500 text-sm px-1 button button-neglect">Already have an account? Sign In</a>
|
||||
<button class="button button-primary font-pirata text-gray-600 text-2xl px-1">
|
||||
<button class="button button-primary text-gray-600 text-2xl px-1">
|
||||
Sign Up
|
||||
</button>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package auth
|
||||
|
||||
templ UserComp(user string) {
|
||||
<div id="user-info" class="flex gap-5 items-center">
|
||||
<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">
|
||||
@@ -37,8 +37,8 @@ templ UserComp(user string) {
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<a href="/auth/signup" class="font-pirata text-xl button px-1 button-neglect">Sign Up</a>
|
||||
<a href="/auth/signin" class="font-pirata text-xl button px-1 button-neglect">Sign In</a>
|
||||
<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>
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package template
|
||||
|
||||
templ Index() {
|
||||
<!-- <div class="h-full flex flex-col items-center justify-center"> -->
|
||||
<div class="h-full flex flex-col items-center justify-center">
|
||||
<h1 class="flex gap-2 w-full justify-center">
|
||||
<img class="w-24" src="/static/favicon.svg" alt="SpendSparrow logo"/>
|
||||
<span class="text-8xl tracking-tighter font-bold font-pirata">SpendSparrow</span>
|
||||
<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
|
||||
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>
|
||||
}
|
||||
@@ -31,7 +31,7 @@ templ Save() {
|
||||
}
|
||||
|
||||
templ Cancel() {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" class="h-4 w-4 text-gray-500">
|
||||
<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>
|
||||
}
|
||||
@@ -47,3 +47,13 @@ templ Info() {
|
||||
<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>
|
||||
}
|
||||
@@ -2,14 +2,15 @@ package transaction
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
import "spend-sparrow/template/svg"
|
||||
import "spend-sparrow/types"
|
||||
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"
|
||||
@@ -52,6 +53,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
|
||||
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"
|
||||
@@ -63,7 +65,25 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -103,7 +123,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
|
||||
if transaction.TreasureChestId != nil {
|
||||
treasureChestId = transaction.TreasureChestId.String()
|
||||
}
|
||||
value = displayBalance(transaction.Value)
|
||||
value = formatFloat(transaction.Value)
|
||||
|
||||
id = transaction.Id.String()
|
||||
cancelUrl = "/transaction/" + id
|
||||
@@ -188,7 +208,9 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
|
||||
hx-swap="outerHTML"
|
||||
class="button button-neglect px-1 flex items-center gap-2"
|
||||
>
|
||||
@svg.Cancel()
|
||||
<span class="h-4 w-4">
|
||||
@svg.Cancel()
|
||||
</span>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
@@ -250,9 +272,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
|
||||
</p>
|
||||
</div>
|
||||
if transaction.Value < 0 {
|
||||
<p class="mr-8 min-w-22 text-right text-red-700">{ displayBalance(transaction.Value)+" €" }</p>
|
||||
<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">{ displayBalance(transaction.Value)+" €" }</p>
|
||||
<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" }
|
||||
@@ -280,11 +302,16 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
|
||||
</div>
|
||||
}
|
||||
|
||||
func displayBalance(balance int64) string {
|
||||
func formatFloat(balance int64) string {
|
||||
|
||||
euros := float64(balance) / 100
|
||||
return fmt.Sprintf("%.2f", euros)
|
||||
}
|
||||
|
||||
func calculateReferences() {
|
||||
func getPageNumber(page string) string {
|
||||
if page == "" {
|
||||
return "1"
|
||||
} else {
|
||||
return page
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package transaction_recurring
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
import "spend-sparrow/template/svg"
|
||||
import "spend-sparrow/types"
|
||||
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 -->
|
||||
@@ -53,9 +53,9 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
|
||||
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
|
||||
</p>
|
||||
if transactionRecurring.Value < 0 {
|
||||
<p class="text-right text-red-700">{ displayBalance(transactionRecurring.Value)+" €" }</p>
|
||||
<p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
|
||||
} else {
|
||||
<p class="text-right text-green-700">{ displayBalance(transactionRecurring.Value)+" €" }</p>
|
||||
<p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
|
||||
}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -104,7 +104,7 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
|
||||
}
|
||||
party = transactionRecurring.Party
|
||||
description = transactionRecurring.Description
|
||||
value = displayBalance(transactionRecurring.Value)
|
||||
value = formatFloat(transactionRecurring.Value)
|
||||
|
||||
id = transactionRecurring.Id.String()
|
||||
}
|
||||
@@ -193,7 +193,9 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
|
||||
hx-swap="outerHTML"
|
||||
class="button button-neglect px-1 flex items-center gap-2"
|
||||
>
|
||||
@svg.Cancel()
|
||||
<span class="h-4 w-4">
|
||||
@svg.Cancel()
|
||||
</span>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
@@ -201,11 +203,8 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
|
||||
</div>
|
||||
}
|
||||
|
||||
func displayBalance(balance int64) string {
|
||||
func formatFloat(balance int64) string {
|
||||
|
||||
euros := float64(balance) / 100
|
||||
return fmt.Sprintf("%.2f", euros)
|
||||
}
|
||||
|
||||
func calculateReferences() {
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
package treasurechest
|
||||
|
||||
import "fmt"
|
||||
import "spend-sparrow/template/svg"
|
||||
import "spend-sparrow/types"
|
||||
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) {
|
||||
@@ -89,7 +88,9 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
|
||||
hx-swap="outerHTML"
|
||||
class="button button-neglect px-1 flex items-center gap-2"
|
||||
>
|
||||
@svg.Cancel()
|
||||
<span class="h-4 w-4">
|
||||
@svg.Cancel()
|
||||
</span>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
@@ -130,14 +131,14 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
|
||||
<p class="mr-auto">{ treasureChest.Name }</p>
|
||||
<p class="mr-20 text-gray-600">
|
||||
if treasureChest.ParentId != nil {
|
||||
+ { displayBalance(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm"> per month</span>
|
||||
+ { 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">{ displayBalance(treasureChest.CurrentBalance) }</p>
|
||||
<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">{ displayBalance(treasureChest.CurrentBalance) }</p>
|
||||
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
|
||||
}
|
||||
}
|
||||
<a
|
||||
@@ -187,9 +188,3 @@ func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.T
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func displayBalance(balance int64) string {
|
||||
|
||||
euros := float64(balance) / 100
|
||||
return fmt.Sprintf("%.2f €", euros)
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -6,13 +6,26 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// At the center of the application is the transaction.
|
||||
// Transaction is at the center of the application.
|
||||
//
|
||||
// Every piece of data should be calculated based on transactions.
|
||||
// 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"`
|
||||
@@ -38,4 +51,5 @@ type TransactionItemsFilter struct {
|
||||
AccountId string
|
||||
TreasureChestId string
|
||||
Error string
|
||||
Page string
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"spend-sparrow/log"
|
||||
)
|
||||
|
||||
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string) {
|
||||
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 {
|
||||
log.Error("Trying to trigger toast in non-HTMX request")
|
||||
slog.ErrorContext(ctx, "Trying to trigger toast in non-HTMX request")
|
||||
}
|
||||
}
|
||||
|
||||
func TriggerToastWithStatus(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
|
||||
TriggerToast(w, r, class, message)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func DoRedirect(w http.ResponseWriter, r *http.Request, url string) {
|
||||
}
|
||||
}
|
||||
|
||||
func WaitMinimumTime[T interface{}](waitTime time.Duration, f func() (T, error)) (T, error) {
|
||||
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))
|
||||
@@ -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: "spendsparrow_error_total",
|
||||
Help: "The total number of errors during processing",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
func Fatal(message string, args ...interface{}) {
|
||||
errorMetric.Inc()
|
||||
|
||||
s := format(message, args)
|
||||
log.Fatal(s)
|
||||
}
|
||||
|
||||
func Error(message string, args ...interface{}) {
|
||||
errorMetric.Inc()
|
||||
|
||||
s := format(message, args)
|
||||
slog.Error(s)
|
||||
}
|
||||
|
||||
func Warn(message string, args ...interface{}) {
|
||||
s := format(message, args)
|
||||
slog.Warn(s)
|
||||
}
|
||||
|
||||
func Info(message string, args ...interface{}) {
|
||||
s := format(message, args)
|
||||
slog.Info(s)
|
||||
}
|
||||
|
||||
func format(message string, args []interface{}) string {
|
||||
var w strings.Builder
|
||||
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintf(&w, message, args...)
|
||||
} else {
|
||||
w.WriteString(message)
|
||||
}
|
||||
|
||||
return w.String()
|
||||
}
|
||||
154
main.go
154
main.go
@@ -1,159 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"spend-sparrow/db"
|
||||
"spend-sparrow/handler"
|
||||
"spend-sparrow/handler/middleware"
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/service"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"context"
|
||||
"net/http"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"spend-sparrow/internal"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/joho/godotenv"
|
||||
_ "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() {
|
||||
ctx := context.Background()
|
||||
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Error loading .env file")
|
||||
slog.ErrorContext(ctx, "Error loading .env file")
|
||||
return
|
||||
}
|
||||
|
||||
db, err := sqlx.Open("sqlite3", "./data/spend-sparrow.db")
|
||||
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
|
||||
otelsql.WithAttributes(semconv.DBSystemSqlite))
|
||||
if err != nil {
|
||||
log.Fatal("Could not open Database data.db: %v", err)
|
||||
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := db.Close()
|
||||
log.Fatal("Could not close Database data.db: %v", err)
|
||||
if err = db.Close(); err != nil {
|
||||
slog.ErrorContext(ctx, "Database close failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = run(context.Background(), db, os.Getenv)
|
||||
if err != nil {
|
||||
log.Error("Error running server: %v", err)
|
||||
if err = internal.Run(context.Background(), db, "", os.Getenv); err != nil {
|
||||
slog.ErrorContext(ctx, "Error running server", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, database *sqlx.DB, env func(string) string) error {
|
||||
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 {
|
||||
return fmt.Errorf("could not run migrations: %w", err)
|
||||
}
|
||||
|
||||
// init servers
|
||||
var prometheusServer *http.Server
|
||||
if serverSettings.PrometheusEnabled {
|
||||
prometheusServer := &http.Server{
|
||||
Addr: ":8081",
|
||||
Handler: promhttp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go startServer(prometheusServer)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + serverSettings.Port,
|
||||
Handler: createHandler(database, serverSettings),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go startServer(httpServer)
|
||||
|
||||
// graceful shutdown
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go shutdownServer(httpServer, ctx, &wg)
|
||||
go shutdownServer(prometheusServer, ctx, &wg)
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startServer(s *http.Server) {
|
||||
log.Info("Starting server on %q", s.Addr)
|
||||
if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("error listening and serving: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
shutdownCtx := context.Background()
|
||||
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(shutdownCtx); err != nil {
|
||||
log.Error("error shutting down http server: %v", err)
|
||||
} else {
|
||||
log.Info("Gracefully stopped http server on %v", s.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
|
||||
var router = http.NewServeMux()
|
||||
|
||||
authDb := db.NewAuthSqlite(d)
|
||||
|
||||
randomService := service.NewRandom()
|
||||
clockService := service.NewClock()
|
||||
mailService := service.NewMail(serverSettings)
|
||||
|
||||
authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings)
|
||||
accountService := service.NewAccount(d, randomService, clockService)
|
||||
treasureChestService := service.NewTreasureChest(d, randomService, clockService)
|
||||
transactionService := service.NewTransaction(d, randomService, clockService)
|
||||
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
|
||||
|
||||
render := handler.NewRender()
|
||||
indexHandler := handler.NewIndex(render)
|
||||
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)
|
||||
|
||||
indexHandler.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/"))))
|
||||
|
||||
return middleware.Wrapper(
|
||||
router,
|
||||
middleware.GenerateRecurringTransactions(transactionRecurringService),
|
||||
middleware.SecurityHeaders(serverSettings),
|
||||
middleware.CacheControl,
|
||||
middleware.CrossSiteRequestForgery(authService),
|
||||
middleware.Authenticate(authService),
|
||||
middleware.Gzip,
|
||||
middleware.Log,
|
||||
)
|
||||
}
|
||||
|
||||
511
package-lock.json
generated
511
package-lock.json
generated
@@ -9,51 +9,32 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.1.8",
|
||||
"htmx.org": "2.0.4",
|
||||
"tailwindcss": "4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"@tailwindcss/cli": "4.1.16",
|
||||
"echarts": "6.0.0",
|
||||
"htmx.org": "2.0.8",
|
||||
"tailwindcss": "4.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
@@ -66,27 +47,17 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"version": "0.3.30",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
|
||||
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -404,73 +375,68 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/cli": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.8.tgz",
|
||||
"integrity": "sha512-+6lkjXSr/68zWiabK3mVYVHmOq/SAHjJ13mR8spyB4LgUWZbWzU9kCSErlAUo+gK5aVfgqe8kY6Ltz9+nz5XYA==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.16.tgz",
|
||||
"integrity": "sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@tailwindcss/node": "4.1.8",
|
||||
"@tailwindcss/oxide": "4.1.8",
|
||||
"enhanced-resolve": "^5.18.1",
|
||||
"@tailwindcss/node": "4.1.16",
|
||||
"@tailwindcss/oxide": "4.1.16",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"mri": "^1.2.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"tailwindcss": "4.1.8"
|
||||
"tailwindcss": "4.1.16"
|
||||
},
|
||||
"bin": {
|
||||
"tailwindcss": "dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz",
|
||||
"integrity": "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"enhanced-resolve": "^5.18.1",
|
||||
"jiti": "^2.4.2",
|
||||
"lightningcss": "1.30.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.30.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.8"
|
||||
"tailwindcss": "4.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz",
|
||||
"integrity": "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
|
||||
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.4",
|
||||
"tar": "^7.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.8",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.8",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.8",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.8",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.8",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.8",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.8",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.8",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.8",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.8",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.8"
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.16",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.16",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz",
|
||||
"integrity": "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
|
||||
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -485,9 +451,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz",
|
||||
"integrity": "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
|
||||
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -502,9 +468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz",
|
||||
"integrity": "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
|
||||
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -519,9 +485,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz",
|
||||
"integrity": "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
|
||||
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -536,9 +502,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz",
|
||||
"integrity": "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
|
||||
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -553,9 +519,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz",
|
||||
"integrity": "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
|
||||
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -570,9 +536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz",
|
||||
"integrity": "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
|
||||
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -587,9 +553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz",
|
||||
"integrity": "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
|
||||
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -604,9 +570,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz",
|
||||
"integrity": "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
|
||||
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -621,9 +587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz",
|
||||
"integrity": "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
|
||||
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -639,30 +605,30 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@emnapi/wasi-threads": "^1.0.2",
|
||||
"@napi-rs/wasm-runtime": "^0.2.10",
|
||||
"@tybys/wasm-util": "^0.9.0",
|
||||
"tslib": "^2.8.0"
|
||||
"@emnapi/core": "^1.5.0",
|
||||
"@emnapi/runtime": "^1.5.0",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.0.7",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
@@ -672,7 +638,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
@@ -682,19 +648,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.10",
|
||||
"version": "1.0.7",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
"@emnapi/core": "^1.5.0",
|
||||
"@emnapi/runtime": "^1.5.0",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
@@ -704,16 +670,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz",
|
||||
"integrity": "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
|
||||
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -728,9 +694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz",
|
||||
"integrity": "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
|
||||
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -744,16 +710,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide/node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -767,16 +723,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
@@ -790,10 +736,21 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -825,9 +782,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/htmx.org": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
|
||||
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
|
||||
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
@@ -865,9 +822,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -875,9 +832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
@@ -891,22 +848,44 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "1.30.1",
|
||||
"lightningcss-darwin-x64": "1.30.1",
|
||||
"lightningcss-freebsd-x64": "1.30.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.1",
|
||||
"lightningcss-linux-arm64-musl": "1.30.1",
|
||||
"lightningcss-linux-x64-gnu": "1.30.1",
|
||||
"lightningcss-linux-x64-musl": "1.30.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.1",
|
||||
"lightningcss-win32-x64-msvc": "1.30.1"
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
|
||||
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -925,9 +904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -946,9 +925,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -967,9 +946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
|
||||
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -988,9 +967,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1009,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1030,9 +1009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1051,9 +1030,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1072,9 +1051,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1093,9 +1072,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1114,9 +1093,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss/node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -1124,13 +1103,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
@@ -1147,45 +1126,6 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
|
||||
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
|
||||
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "dist/cjs/src/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -1234,40 +1174,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
|
||||
"integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
|
||||
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
|
||||
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
|
||||
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.0.1",
|
||||
"mkdirp": "^3.0.1",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -1281,14 +1203,21 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "spend-sparrow",
|
||||
"version": "1.0.0",
|
||||
"description": "Your (almost) independent tech stack to host on a VPC.",
|
||||
"description": "Personal finance tracking done right",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
|
||||
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
|
||||
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
|
||||
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"htmx.org": "2.0.4",
|
||||
"tailwindcss": "4.1.8",
|
||||
"@tailwindcss/cli": "4.1.8"
|
||||
"@tailwindcss/cli": "4.1.16",
|
||||
"htmx.org": "2.0.8",
|
||||
"tailwindcss": "4.1.16",
|
||||
"echarts": "6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"spend-sparrow/log"
|
||||
"spend-sparrow/types"
|
||||
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Random interface {
|
||||
Bytes(size int) ([]byte, error)
|
||||
String(size int) (string, error)
|
||||
UUID() (uuid.UUID, error)
|
||||
}
|
||||
|
||||
type RandomImpl struct {
|
||||
}
|
||||
|
||||
func NewRandom() *RandomImpl {
|
||||
return &RandomImpl{}
|
||||
}
|
||||
|
||||
func (r *RandomImpl) Bytes(size int) ([]byte, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
log.Error("Error generating random bytes: %v", err)
|
||||
return []byte{}, types.ErrInternal
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (r *RandomImpl) String(size int) (string, error) {
|
||||
bytes, err := r.Bytes(size)
|
||||
if err != nil {
|
||||
log.Error("Error generating random string: %v", err)
|
||||
return "", types.ErrInternal
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (r *RandomImpl) UUID() (uuid.UUID, error) {
|
||||
id, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
log.Error("Error generating random UUID: %v", err)
|
||||
return uuid.Nil, types.ErrInternal
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
@@ -1 +1,53 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M93.46 39.45c6.71-1.49 15.45-8.15 16.78-11.43.78-1.92-3.11-4.92-4.15-6.13-2.38-2.76-1.42-4.12-.5-7.41 1.05-3.74-1.44-7.87-4.97-9.49s-7.75-1.11-11.3.47-6.58 4.12-9.55 6.62c-2.17-1.37-5.63-7.42-11.23-3.49-3.87 2.71-4.22 8.61-3.72 13.32 1.17 10.87 3.85 16.51 8.9 18.03 6.38 1.92 13.44.91 19.74-.49" style="fill:#ffca28"/><path d="M104.36 8.18c-.85 14.65-15.14 24.37-21.92 28.65l4.4 3.78s2.79.06 6.61-1.16c6.55-2.08 16.12-7.96 16.78-11.43.97-5.05-4.21-3.95-5.38-7.94-.61-2.11 2.97-6.1-.49-11.9M79.78 12.09s-2.55-2.61-4.44-3.8c-.94 1.77-1.61 3.69-1.94 5.67-.59 3.48 0 8.42 1.39 12.1.22.57 1.04.48 1.13-.12 1.2-7.91 3.86-13.85 3.86-13.85" style="fill:#e2a610"/><path d="M61.96 38.16S30.77 41.53 16.7 68.61s-2.11 43.5 10.55 49.48 44.56 8.09 65.31 3.17 25.94-15.12 24.97-24.97c-1.41-14.38-14.77-23.22-14.77-23.22s.53-17.76-13.25-29.29c-12.23-10.24-27.55-5.62-27.55-5.62" style="fill:#ffca28"/><path d="M74.76 83.73c-6.69-8.44-14.59-9.57-17.12-12.6-1.38-1.65-2.19-3.32-1.88-5.39.33-2.2 2.88-3.72 4.86-4.09 2.31-.44 7.82-.21 12.45 4.2 1.1 1.04.7 2.66.67 4.11-.08 3.11 4.37 6.13 7.97 3.53 3.61-2.61.84-8.42-1.49-11.24-1.76-2.13-8.14-6.82-16.07-7.56-2.23-.21-11.2-1.54-16.38 8.31-1.49 2.83-2.04 9.67 5.76 15.45 1.63 1.21 10.09 5.51 12.44 8.3 4.07 4.83 1.28 9.08-1.9 9.64-8.67 1.52-13.58-3.17-14.49-5.74-.65-1.83.03-3.81-.81-5.53-.86-1.77-2.62-2.47-4.48-1.88-6.1 1.94-4.16 8.61-1.46 12.28 2.89 3.93 6.44 6.3 10.43 7.6 14.89 4.85 22.05-2.81 23.3-8.42.92-4.11.82-7.67-1.8-10.97" style="fill:#6b4b46"/><path d="M71.16 48.99c-12.67 27.06-14.85 61.23-14.85 61.23" style="fill:none;stroke:#6b4b46;stroke-width:5;stroke-miterlimit:10"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6d4c41"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6b4b46"/><path d="M96.49 58.86c1.06-.73 4.62.53 5.62 7.5.49 3.41.64 6.71.64 6.71s-4.2-3.77-5.59-6.42c-1.75-3.35-2.43-6.59-.67-7.79" style="fill:#e2a610"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="28.384802mm"
|
||||
height="31.749905mm"
|
||||
viewBox="0 0 28.384802 31.749905"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-37.253301,-88.598061)">
|
||||
<path
|
||||
d="m 59.240389,97.978247 c 1.775354,-0.394229 4.087813,-2.156354 4.439709,-3.024187 0.206375,-0.508 -0.822855,-1.30175 -1.098021,-1.621896 -0.629709,-0.73025 -0.375709,-1.090083 -0.132292,-1.960562 0.277813,-0.989542 -0.381,-2.082271 -1.314979,-2.510896 -0.933979,-0.428625 -2.050521,-0.293688 -2.989792,0.124354 -0.939271,0.418042 -1.740958,1.090083 -2.526771,1.751542 -0.574145,-0.36248 -1.489604,-1.963209 -2.97127,-0.923396 -1.023938,0.717021 -1.116542,2.278062 -0.98425,3.52425 0.309562,2.876021 1.018645,4.368271 2.354791,4.770437 1.688042,0.508 3.556,0.240771 5.222875,-0.129646"
|
||||
style="fill:#ffca28;stroke-width:0.264583"
|
||||
id="path1-3" />
|
||||
<path
|
||||
d="m 62.124348,89.704727 c -0.224896,3.876145 -4.005792,6.447895 -5.799667,7.580312 l 1.164167,1.000125 c 0,0 0.738187,0.01588 1.748895,-0.306917 1.733021,-0.550333 4.265084,-2.106083 4.439709,-3.024187 0.256646,-1.336146 -1.113896,-1.045104 -1.423459,-2.100792 -0.161395,-0.558271 0.785813,-1.613958 -0.129645,-3.148541 m -6.503459,1.03452 c 0,0 -0.674687,-0.690562 -1.17475,-1.005416 -0.248708,0.468312 -0.425979,0.976312 -0.513291,1.500187 -0.156105,0.92075 0,2.227792 0.36777,3.201459 0.05821,0.150812 0.275167,0.127 0.29898,-0.03175 0.3175,-2.092855 1.021291,-3.66448 1.021291,-3.66448"
|
||||
style="fill:#e2a610;stroke-width:0.264583"
|
||||
id="path2-6" />
|
||||
<path
|
||||
d="m 50.906014,97.636935 c 0,0 -8.252354,0.891646 -11.975042,8.056565 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490104,-1.30175 6.863292,-4.0005 6.606646,-6.60664 -0.373062,-3.80471 -3.907896,-6.14363 -3.907896,-6.14363 0,0 0.140229,-4.699 -3.505729,-7.749647 -3.235854,-2.709333 -7.289271,-1.486958 -7.289271,-1.486958"
|
||||
style="fill:#ffca28;stroke-width:0.264583"
|
||||
id="path3-0" />
|
||||
<path
|
||||
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
|
||||
style="fill:#6d4c41;stroke-width:0.264583"
|
||||
id="path6-6" />
|
||||
<path
|
||||
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
|
||||
style="fill:#6b4b46;stroke-width:0.264583"
|
||||
id="path7-2" />
|
||||
<path
|
||||
d="m 60.042077,103.11381 c 0.280458,-0.19314 1.222375,0.14023 1.486958,1.98438 0.129646,0.90223 0.169333,1.77535 0.169333,1.77535 0,0 -1.11125,-0.99748 -1.47902,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
|
||||
style="fill:#e2a610;stroke-width:0.264583"
|
||||
id="path8-6" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
|
||||
x="82.355011"
|
||||
y="90.66716"
|
||||
id="text4-9"
|
||||
transform="rotate(20.578693)"><tspan
|
||||
id="tspan4-2"
|
||||
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
|
||||
x="82.355011"
|
||||
y="90.66716">$</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
static/font/EBGaramond-VariableFont_wght.woff2
Normal file
BIN
static/font/EBGaramond-VariableFont_wght.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user