10 Commits

Author SHA1 Message Date
admin c3f90b57c2 Refactored transaction input handling and removed unused receipt-related definitions in Swagger. 2026-05-09 12:53:48 +03:00
admin a57f918d23 Updated transaction routers, removed receipts router 2026-05-09 12:05:02 +03:00
admin 2dc8ff01b7 Added activities module 2026-04-11 11:51:18 +03:00
admin 8e074db55f Added analytics by transactions 2026-04-11 11:37:48 +03:00
admin b66be96033 Added uploading receipt photo to API 2026-04-11 11:26:58 +03:00
admin 545b05d5a0 Added transaction feature, fixed some mistakes 2026-04-11 11:13:12 +03:00
admin 6872563c62 Added frontend localization 2026-04-05 22:46:52 +03:00
admin 4902889401 Added vue frontend project, fixed swagger path 2026-04-05 21:51:03 +03:00
admin 9d845c8899 Restructured project
- backend moved to backend directory
- added and initialized frontend with vue
- moved infrastructure files to infra directory
2026-04-01 23:16:27 +03:00
admin 48ef7217eb Updated API and Bot.
- added auth
- updated structure
2026-04-01 22:16:26 +03:00
151 changed files with 13536 additions and 2738 deletions
+3 -1
View File
@@ -4,4 +4,6 @@
secret_key.json secret_key.json
data data
archive archive
volumes volumes
*.dtmp
*.gocache
+75 -1
View File
@@ -1,4 +1,4 @@
<mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.5.2 Chrome/142.0.7444.265 Electron/39.6.1 Safari/537.36" version="29.5.2"> <mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.5.2 Chrome/142.0.7444.265 Electron/39.6.1 Safari/537.36" version="29.5.2" pages="2">
<diagram name="Страница-1" id="0m6B3G-Z3EdFeOiLiUiD"> <diagram name="Страница-1" id="0m6B3G-Z3EdFeOiLiUiD">
<mxGraphModel dx="1357" dy="1036" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0"> <mxGraphModel dx="1357" dy="1036" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root> <root>
@@ -619,4 +619,78 @@
</root> </root>
</mxGraphModel> </mxGraphModel>
</diagram> </diagram>
<diagram id="8MaqrHVdWClXsSExB3yQ" name="Страница-2">
<mxGraphModel dx="1018" dy="777" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-6" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="From TG" vertex="1">
<mxGeometry height="60" width="120" x="170" y="40" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-7" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="From browser" vertex="1">
<mxGeometry height="60" width="120" x="170" y="210" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-8" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="InitDATA" vertex="1">
<mxGeometry height="30" width="60" x="440" y="40" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-9" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="telegramId" vertex="1">
<mxGeometry height="30" width="60" x="360" y="210" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-10" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="TOKEN" vertex="1">
<mxGeometry height="30" width="60" x="440" y="70" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-11" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-6" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-8" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="350" as="sourcePoint" />
<mxPoint x="550" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-12" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-10" style="endArrow=classic;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-6" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="350" as="sourcePoint" />
<mxPoint x="550" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-13" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-7" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-9" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-17" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-7">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-18" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="TG" vertex="1">
<mxGeometry height="60" width="120" x="170" y="290" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-19" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="OTP" vertex="1">
<mxGeometry height="30" width="60" x="360" y="320" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-20" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-19" style="endArrow=classic;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-18" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-21" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="OTP" vertex="1">
<mxGeometry height="30" width="60" x="460" y="225" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-22" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-7" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-21" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-23" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="Token" vertex="1">
<mxGeometry height="30" width="60" x="460" y="255" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-24" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-23" style="endArrow=classic;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-7" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile> </mxfile>
+23
View File
@@ -0,0 +1,23 @@
.PHONY: generate_swagger run_standalone run_bot run_api run_frontend build_backend build_frontend
generate_swagger:
swag init -g backend/src/api/server.go -o backend/src/api/docs
run_standalone:
cd backend && export RUN_MODE=standalone && go run ./src
run_bot:
cd backend && export RUN_MODE=bot && go run ./src
run_api:
cd backend && export RUN_MODE=api && go run ./src
run_frontend:
cd frontend && npm run dev
build_backend:
mkdir -p output
cd backend && go build -o ../output/familyhub ./src
build_frontend:
cd frontend && npm run build
+86
View File
@@ -0,0 +1,86 @@
# FamilyHUB
## Структура репозитория
- `backend/` — текущий Go backend и миграции приложения.
- `frontend/` — директория под Vue-приложение.
- `infra/` — docker-compose, Dockerfile'ы и локальные infra-данные для разработки.
- `docs/` — документация проекта.
## Заполнение конфигурации
Приложение читает переменные окружения из `.env` (через `godotenv`) и затем из окружения процесса.
### 1. Создайте файл `.env` в `backend/`
```env
RUN_MODE=standalone
DEBUG_MODE=false
BOT_TOKEN=123456:telegram-bot-token
GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/google-credentials.json
DB_PATH=sqlite://data/app.db
API_HOST=localhost
API_PORT=8000
API_SECRET=change-me
OPEN_API_ENABLED=true
OPEN_API_ENDPOINT=/openapi
```
### 2. Обязательные переменные по режимам
`RUN_MODE` поддерживает значения:
- `bot`
- `api`
- `standalone`
Если `RUN_MODE=bot` или `RUN_MODE=standalone`, обязательны:
- `BOT_TOKEN`
- `GOOGLE_APPLICATION_CREDENTIALS`
Если `RUN_MODE=api` или `RUN_MODE=standalone`, обязательна:
- `API_SECRET`
### 3. Дефолты для API-режима
Если не заданы, будут использованы:
- `DB_PATH=sqlite://data/app.db`
- `API_HOST=localhost`
- `API_PORT=8000`
- `OPEN_API_ENDPOINT=/openapi`
### 4. Описание переменных
- `RUN_MODE`: режим запуска (`bot`, `api`, `standalone`).
- `DEBUG_MODE`: `true/false`.
- `BOT_TOKEN`: токен Telegram-бота.
- `GOOGLE_APPLICATION_CREDENTIALS`: абсолютный путь к JSON-ключу Google.
- `DB_PATH`: строка подключения к БД (например `sqlite://data/app.db`).
- `API_HOST`: хост API.
- `API_PORT`: порт API.
- `API_SECRET`: секрет API.
- `OPEN_API_ENABLED`: включает swagger-ui endpoint (`true/false`).
- `OPEN_API_ENDPOINT`: путь для OpenAPI endpoint (в конфиге присутствует).
### 5. Быстрая проверка перед запуском
1. Убедитесь, что `RUN_MODE` выставлен корректно.
2. Проверьте обязательные переменные для выбранного режима.
3. Проверьте существование файла `GOOGLE_APPLICATION_CREDENTIALS` (если включен bot).
4. Убедитесь, что `DB_PATH` валиден и директория для SQLite доступна на запись.
### 6. Запуск backend
```bash
cd backend
go run ./src
```
### 7. Запуск инфраструктуры
```bash
cd infra
docker compose up -d
```
+4 -8
View File
@@ -6,12 +6,16 @@ require (
cloud.google.com/go/vision v1.2.0 cloud.google.com/go/vision v1.2.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea
github.com/mattn/go-sqlite3 v1.14.34 github.com/mattn/go-sqlite3 v1.14.34
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
) )
require ( require (
@@ -28,7 +32,6 @@ require (
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -59,14 +62,8 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
@@ -91,5 +88,4 @@ require (
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
) )
+825
View File
@@ -0,0 +1,825 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8=
cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/vision v1.2.0 h1:/CsSTkbmO9HC8iQpxbK8ATms3OQaX3YQUeTMGCxlaK4=
cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0=
cloud.google.com/go/vision/v2 v2.9.5 h1:UJZ0H6UlOaYKgCn6lWG2iMAOJIsJZLnseEfzBR8yIqQ=
cloud.google.com/go/vision/v2 v2.9.5/go.mod h1:1SiNZPpypqZDbOzU052ZYRiyKjwOcyqgGgqQCI/nlx8=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
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-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea h1:uyJ13zfy6l79CM3HnVhDalIyZ4RJAyVfDrbnfFeJoC4=
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea/go.mod h1:w4pGU9PkiX2hAWyF0yuHEHmYTQFAd6WHzp6+IY7JVjE=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
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/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
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/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc=
google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
@@ -1,7 +1,7 @@
CREATE TABLE users CREATE TABLE users
( (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
telegram_id BIGINT UNIQUE NOT NULL, telegram_id BIGINT UNIQUE,
username TEXT, username TEXT,
first_name TEXT NOT NULL, first_name TEXT NOT NULL,
last_name TEXT, last_name TEXT,
@@ -3,7 +3,7 @@ CREATE TABLE families
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
owner_id BIGINT NOT NULL REFERENCES users (id), owner_id BIGINT NOT NULL REFERENCES users (id),
telegram_chat_id BIGINT NOT NULL, telegram_chat_id BIGINT,
telegram_chat_name TEXT, telegram_chat_name TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
@@ -0,0 +1,3 @@
SELECT cron.unschedule('cleanup-expired-otp');
DROP TABLE IF EXISTS otp;
@@ -0,0 +1,17 @@
CREATE UNLOGGED TABLE otp
(
user_id BIGINT NOT NULL,
otp TEXT NOT NULL,
expired_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_otp_user_id ON otp (user_id);
CREATE INDEX idx_otp_expired_at ON otp (expired_at);
SELECT cron.schedule(
'cleanup-expired-otp',
'*/10 * * * *',
$$DELETE FROM otp WHERE expired_at <= NOW()$$
);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS receipts;
DROP TABLE IF EXISTS transactions;
@@ -0,0 +1,55 @@
CREATE TABLE transactions
(
id BIGSERIAL PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE,
description TEXT,
type TEXT NOT NULL,
datetime TIMESTAMP NOT NULL,
category TEXT NOT NULL,
amount NUMERIC(14, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE RESTRICT
);
CREATE INDEX idx_transactions_family_id ON transactions (family_id);
CREATE INDEX idx_transactions_datetime ON transactions (datetime);
CREATE INDEX idx_transactions_created_by ON transactions (created_by);
CREATE INDEX idx_transactions_family_datetime ON transactions (family_id, datetime DESC);
CREATE TABLE receipts
(
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT UNIQUE REFERENCES transactions (id) ON DELETE SET NULL,
receipt_number TEXT NOT NULL UNIQUE,
ui TEXT NOT NULL,
status INTEGER NOT NULL,
issued_at TIMESTAMP NOT NULL,
total_amount REAL NOT NULL,
payment_amount REAL NOT NULL,
cash_amount REAL NOT NULL,
another_amount REAL NOT NULL,
clearing_amount REAL NOT NULL,
margin REAL NOT NULL,
currency TEXT NOT NULL,
payment_type INTEGER NOT NULL,
cashbox_number INTEGER NOT NULL,
cashier TEXT,
name_spd TEXT,
name_to TEXT,
name_np TEXT,
type_np TEXT,
street_to TEXT,
house_to TEXT,
kod_soato TEXT,
oblast_soato TEXT,
rayon_soato TEXT,
selsovet_soato TEXT,
doc_num TEXT,
skno_number TEXT,
unp TEXT,
success TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_receipts_issued_at ON receipts (issued_at);
CREATE INDEX idx_receipts_transaction_id ON receipts (transaction_id);
@@ -0,0 +1,10 @@
DO
$$
BEGIN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly') THEN
PERFORM cron.unschedule((SELECT jobid FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly'));
END IF;
END
$$;
DROP TABLE IF EXISTS activity_logs;
@@ -0,0 +1,30 @@
CREATE UNLOGGED TABLE activity_logs
(
id BIGSERIAL PRIMARY KEY,
family_id BIGINT REFERENCES families (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id BIGINT,
description TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_activity_logs_created_at ON activity_logs (created_at DESC);
CREATE INDEX idx_activity_logs_user_id ON activity_logs (user_id);
CREATE INDEX idx_activity_logs_family_id ON activity_logs (family_id);
DO
$$
BEGIN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly') THEN
PERFORM cron.unschedule((SELECT jobid FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly'));
END IF;
END
$$;
SELECT cron.schedule(
'cleanup_activity_logs_hourly',
'0 * * * *',
$$DELETE FROM activity_logs WHERE created_at < NOW() - INTERVAL '1 day'$$
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+780
View File
@@ -0,0 +1,780 @@
definitions:
domain.CreateFamilyRequest:
properties:
name:
type: string
owner_id:
type: integer
telegram_chat_id:
type: integer
telegram_chat_name:
type: string
type: object
domain.CreateUserRequest:
properties:
first_name:
type: string
language_code:
type: string
last_name:
type: string
telegram_id:
type: integer
username:
type: string
required:
- first_name
- telegram_id
type: object
domain.FamilyResponse:
properties:
created_at:
type: string
id:
type: integer
name:
type: string
owner_id:
type: integer
telegram_chat_id:
type: integer
telegram_chat_name:
type: string
updated_at:
type: string
type: object
domain.UpdateFamilyRequest:
properties:
name:
type: string
telegram_chat_id:
type: integer
telegram_chat_name:
type: string
type: object
domain.UpdateUserRequest:
properties:
first_name:
type: string
language_code:
type: string
last_name:
type: string
username:
type: string
type: object
domain.UserResponse:
properties:
created_at:
type: string
first_name:
type: string
id:
type: integer
language_code:
type: string
last_name:
type: string
telegram_id:
type: integer
updated_at:
type: string
username:
type: string
type: object
dto.ActivityListResponse:
properties:
items:
items:
$ref: '#/definitions/dto.ActivityResponse'
type: array
limit:
type: integer
offset:
type: integer
type: object
dto.ActivityResponse:
properties:
action:
type: string
created_at:
type: string
description:
type: string
entity_id:
type: integer
entity_type:
type: string
family_id:
type: integer
id:
type: integer
user_id:
type: integer
type: object
dto.CreateTransactionRequest:
properties:
amount:
type: number
category:
type: string
created_by:
type: integer
datetime:
type: string
description:
type: string
family_id:
type: integer
receipt_date:
type: string
receipt_id:
type: integer
receipt_number:
type: string
type:
type: string
type: object
dto.ErrorResponse:
properties:
message:
type: string
type: object
dto.TransactionAnalyticsResponse:
properties:
expenses:
type: number
incomes:
type: number
total:
type: number
type: object
dto.TransactionListResponse:
properties:
items:
items:
$ref: '#/definitions/dto.TransactionResponse'
type: array
type: object
dto.TransactionResponse:
properties:
amount:
type: number
category:
type: string
created_at:
type: string
created_by:
type: integer
datetime:
type: string
description:
type: string
family_id:
type: integer
id:
type: integer
receipt_id:
type: integer
type:
type: string
type: object
dto.UpdateTransactionRequest:
properties:
amount:
type: number
category:
type: string
datetime:
type: string
description:
type: string
detach_receipt:
type: boolean
receipt_id:
type: integer
type:
type: string
type: object
info:
contact: {}
paths:
/api/v1/activities:
get:
consumes:
- application/json
description: Возвращает список действий пользователей с пагинацией
parameters:
- description: Family ID
in: query
name: family_id
type: integer
- description: User ID
in: query
name: user_id
type: integer
- description: Limit, default 10
in: query
name: limit
type: integer
- description: Offset
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.ActivityListResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить активность пользователей
tags:
- Activities
/api/v1/families:
post:
consumes:
- application/json
description: Создает новую семью
parameters:
- description: Family info
in: body
name: family
required: true
schema:
$ref: '#/definitions/domain.CreateFamilyRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.FamilyResponse'
"400":
description: invalid body
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Создать семью
tags:
- Families
/api/v1/families/{id}:
delete:
consumes:
- application/json
description: Удаляет семью по ее ID
parameters:
- description: Family ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: no content
schema:
type: string
"400":
description: invalid id
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: family not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Удалить семью
tags:
- Families
get:
consumes:
- application/json
description: Возвращает семью по ее внутреннему ID
parameters:
- description: Family ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.FamilyResponse'
"400":
description: invalid id
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: family not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить семью по ID
tags:
- Families
patch:
consumes:
- application/json
description: Частично обновляет данные семьи по ID
parameters:
- description: Family ID
in: path
name: id
required: true
type: integer
- description: Данные для обновления
in: body
name: family
required: true
schema:
$ref: '#/definitions/domain.UpdateFamilyRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.FamilyResponse'
"400":
description: name is required
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: family not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Обновить семью
tags:
- Families
/api/v1/transactions:
get:
consumes:
- application/json
description: Возвращает список транзакций с фильтрами и пагинацией
parameters:
- description: Family ID
in: query
name: family_id
type: integer
- description: User ID
in: query
name: created_by
type: integer
- description: Transaction type
in: query
name: type
type: string
- description: Category
in: query
name: category
type: string
- description: RFC3339 start datetime
in: query
name: date_from
type: string
- description: RFC3339 end datetime
in: query
name: date_to
type: string
- description: Limit, default 50
in: query
name: limit
type: integer
- description: Offset
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionListResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить список транзакций
tags:
- Transactions
post:
consumes:
- application/json
- multipart/form-data
description: |-
Создает транзакцию одним из трех способов.
1. application/json: ручная транзакция с полями family_id, created_by, type, category, amount, datetime.
2. application/json: транзакция по чеку с полями family_id, created_by, receipt_number, receipt_date.
3. multipart/form-data: транзакция по фото чека с полями photo, family_id, created_by и опциональными type, category, description.
В одном JSON-запросе нельзя смешивать ручные поля транзакции с полями receipt_number и receipt_date.
parameters:
- description: JSON payload for manual or receipt-based transaction creation
in: body
name: transaction
schema:
$ref: '#/definitions/dto.CreateTransactionRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/dto.TransactionResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Создать транзакцию
tags:
- Transactions
/api/v1/transactions/{id}:
delete:
consumes:
- application/json
description: Удаляет транзакцию по ID
parameters:
- description: Transaction ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: no content
schema:
type: string
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Удалить транзакцию
tags:
- Transactions
get:
consumes:
- application/json
description: Возвращает транзакцию по ее внутреннему ID
parameters:
- description: Transaction ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить транзакцию по ID
tags:
- Transactions
patch:
consumes:
- application/json
description: Частично обновляет данные транзакции и связь с чеком
parameters:
- description: Transaction ID
in: path
name: id
required: true
type: integer
- description: Transaction patch payload
in: body
name: transaction
required: true
schema:
$ref: '#/definitions/dto.UpdateTransactionRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Обновить транзакцию
tags:
- Transactions
/api/v1/transactions/analytics:
get:
consumes:
- application/json
description: Возвращает расходы, доходы и total за период. При фильтре по type
второй тип возвращается как 0.
parameters:
- description: Family ID
in: query
name: family_id
type: integer
- description: 'Transaction type: income or expense'
in: query
name: type
type: string
- description: RFC3339 start datetime
in: query
name: date_from
required: true
type: string
- description: RFC3339 end datetime
in: query
name: date_to
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionAnalyticsResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить аналитику по транзакциям
tags:
- Transactions
/api/v1/users:
post:
consumes:
- application/json
parameters:
- description: User info
in: body
name: user
required: true
schema:
$ref: '#/definitions/domain.CreateUserRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.UserResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Создать пользователя
tags:
- Users
/api/v1/users/{id}:
delete:
consumes:
- application/json
description: Удаляет пользователя по его ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: no content
schema:
type: string
"400":
description: invalid id
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: user not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Удалить пользователя
tags:
- Users
get:
consumes:
- application/json
description: Возвращает пользователя по его внутреннему ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.UserResponse'
"400":
description: invalid id
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: user not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить пользователя по ID
tags:
- Users
patch:
consumes:
- application/json
description: Частично обновляет данные пользователя по ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
- description: Данные для обновления
in: body
name: user
required: true
schema:
$ref: '#/definitions/domain.UpdateUserRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.UserResponse'
"400":
description: invalid id or invalid body
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: user not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Обновить пользователя
tags:
- Users
/api/v1/users/by-telegram/{telegramId}:
get:
consumes:
- application/json
description: Возвращает пользователя по его Telegram ID
parameters:
- description: Telegram ID
in: path
name: telegramId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.UserResponse'
"400":
description: invalid telegram id
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: user not found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: internal server error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить пользователя по Telegram ID
tags:
- Users
swagger: "2.0"
+56
View File
@@ -0,0 +1,56 @@
package dto
import (
"FamilyHub/src/domain"
"time"
)
type ActivityListQuery struct {
FamilyID *int64 `form:"family_id"`
UserID *int64 `form:"user_id"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
type ActivityResponse struct {
ID int64 `json:"id"`
FamilyID *int64 `json:"family_id"`
UserID int64 `json:"user_id"`
Action string `json:"action"`
EntityType string `json:"entity_type"`
EntityID *int64 `json:"entity_id"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
type ActivityListResponse struct {
Items []ActivityResponse `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func ActivityToResponse(activity *domain.ActivityLog) ActivityResponse {
return ActivityResponse{
ID: activity.ID,
FamilyID: activity.FamilyID,
UserID: activity.UserID,
Action: activity.Action,
EntityType: activity.EntityType,
EntityID: activity.EntityID,
Description: activity.Description,
CreatedAt: activity.CreatedAt.Format(time.RFC3339),
}
}
func ActivitiesToListResponse(activities []*domain.ActivityLog, limit, offset int) ActivityListResponse {
items := make([]ActivityResponse, 0, len(activities))
for _, activity := range activities {
items = append(items, ActivityToResponse(activity))
}
return ActivityListResponse{
Items: items,
Limit: limit,
Offset: offset,
}
}
+102
View File
@@ -0,0 +1,102 @@
package dto
import (
"FamilyHub/src/domain"
"time"
)
type CreateTransactionRequest struct {
FamilyID *int64 `json:"family_id"`
Description *string `json:"description"`
Type *string `json:"type"`
DateTime *string `json:"datetime"`
Category *string `json:"category"`
Amount *float64 `json:"amount"`
CreatedBy *int64 `json:"created_by"`
ReceiptID *int64 `json:"receipt_id"`
ReceiptNumber *string `json:"receipt_number"`
ReceiptDate *string `json:"receipt_date"`
}
type UpdateTransactionRequest struct {
Description *string `json:"description"`
Type *string `json:"type"`
DateTime *string `json:"datetime"`
Category *string `json:"category"`
Amount *float64 `json:"amount"`
ReceiptID *int64 `json:"receipt_id"`
DetachReceipt bool `json:"detach_receipt"`
}
type ListTransactionsQuery struct {
FamilyID *int64 `form:"family_id"`
CreatedBy *int64 `form:"created_by"`
Type *string `form:"type"`
Category *string `form:"category"`
DateFrom *string `form:"date_from"`
DateTo *string `form:"date_to"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
type TransactionAnalyticsQuery struct {
FamilyID *int64 `form:"family_id"`
Type *string `form:"type"`
DateFrom string `form:"date_from" binding:"required"`
DateTo string `form:"date_to" binding:"required"`
}
type TransactionResponse struct {
ID int64 `json:"id"`
FamilyID int64 `json:"family_id"`
Description *string `json:"description"`
Type string `json:"type"`
DateTime string `json:"datetime"`
Category string `json:"category"`
Amount float64 `json:"amount"`
CreatedAt string `json:"created_at"`
CreatedBy int64 `json:"created_by"`
ReceiptID *int64 `json:"receipt_id"`
}
type TransactionListResponse struct {
Items []TransactionResponse `json:"items"`
}
type TransactionAnalyticsResponse struct {
Expenses float64 `json:"expenses"`
Incomes float64 `json:"incomes"`
Total float64 `json:"total"`
}
func TransactionToResponse(transaction *domain.Transaction) TransactionResponse {
return TransactionResponse{
ID: transaction.ID,
FamilyID: transaction.FamilyID,
Description: transaction.Description,
Type: transaction.Type,
DateTime: transaction.DateTime.Format(time.RFC3339),
Category: transaction.Category,
Amount: transaction.Amount,
CreatedAt: transaction.CreatedAt.Format(time.RFC3339),
CreatedBy: transaction.CreatedBy,
ReceiptID: transaction.ReceiptID,
}
}
func TransactionsToListResponse(transactions []*domain.Transaction) TransactionListResponse {
items := make([]TransactionResponse, 0, len(transactions))
for _, transaction := range transactions {
items = append(items, TransactionToResponse(transaction))
}
return TransactionListResponse{Items: items}
}
func TransactionAnalyticsToResponse(analytics domain.TransactionAnalytics) TransactionAnalyticsResponse {
return TransactionAnalyticsResponse{
Expenses: analytics.Expenses,
Incomes: analytics.Incomes,
Total: analytics.Total,
}
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
func requestLoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
startedAt := time.Now()
log.Printf(
"request started: method=%s path=%s query=%s client_ip=%s",
c.Request.Method,
c.Request.URL.Path,
c.Request.URL.RawQuery,
c.ClientIP(),
)
c.Next()
finishedAt := time.Since(startedAt)
if len(c.Errors) > 0 {
log.Printf(
"request finished with errors: method=%s path=%s status=%d latency=%s errors=%s",
c.Request.Method,
c.Request.URL.Path,
c.Writer.Status(),
finishedAt,
c.Errors.String(),
)
return
}
log.Printf(
"request finished: method=%s path=%s status=%d latency=%s",
c.Request.Method,
c.Request.URL.Path,
c.Writer.Status(),
finishedAt,
)
}
}
+15
View File
@@ -0,0 +1,15 @@
package requests
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/domain"
)
func BuildActivityListFilter(query dto.ActivityListQuery) domain.ActivityLogListFilter {
return domain.ActivityLogListFilter{
FamilyID: query.FamilyID,
UserID: query.UserID,
Limit: query.Limit,
Offset: query.Offset,
}
}
+16
View File
@@ -0,0 +1,16 @@
package requests
import (
"errors"
"strconv"
"strings"
)
func ParseInt64(value string, invalidMessage string) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, errors.New(invalidMessage)
}
return parsed, nil
}
+16
View File
@@ -0,0 +1,16 @@
package requests
import (
"FamilyHub/src/domain"
"errors"
)
var ErrFamilyNameRequired = errors.New("name is required")
func ValidateFamilyUpdate(req domain.UpdateFamilyRequest) error {
if req.Name == nil {
return ErrFamilyNameRequired
}
return nil
}
+127
View File
@@ -0,0 +1,127 @@
package requests
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"FamilyHub/src/utils"
"errors"
"strings"
"time"
)
type PhotoCreateTransactionFields struct {
Image []byte
FamilyID *int64
CreatedBy *int64
Type *string
Category *string
Description *string
}
func BuildCreateTransactionInput(req dto.CreateTransactionRequest) (services.CreateTransactionInput, error) {
if req.ReceiptNumber != nil || req.ReceiptDate != nil {
receiptReq, err := BuildReceiptTransactionRequest(req)
if err != nil {
return services.CreateTransactionInput{}, err
}
return services.CreateTransactionInput{Receipt: &receiptReq}, nil
}
manualReq, err := BuildManualTransactionRequest(req)
if err != nil {
return services.CreateTransactionInput{}, err
}
return services.CreateTransactionInput{Manual: &manualReq}, nil
}
func BuildPhotoCreateTransactionInput(fields PhotoCreateTransactionFields) (services.CreateTransactionInput, error) {
return services.CreateTransactionInput{
Photo: &services.CreateTransactionPhotoInput{
Image: fields.Image,
FamilyID: fields.FamilyID,
CreatedBy: fields.CreatedBy,
Type: trimOptionalString(fields.Type),
Category: trimOptionalString(fields.Category),
Description: trimOptionalString(fields.Description),
},
}, nil
}
func BuildManualTransactionRequest(req dto.CreateTransactionRequest) (domain.CreateTransactionRequest, error) {
if req.FamilyID == nil || req.CreatedBy == nil {
return domain.CreateTransactionRequest{}, errors.New("family_id and created_by are required")
}
if req.Type == nil || strings.TrimSpace(*req.Type) == "" {
return domain.CreateTransactionRequest{}, errors.New("type is required")
}
if req.Category == nil || strings.TrimSpace(*req.Category) == "" {
return domain.CreateTransactionRequest{}, errors.New("category is required")
}
if req.Amount == nil {
return domain.CreateTransactionRequest{}, errors.New("amount is required")
}
if req.DateTime == nil || strings.TrimSpace(*req.DateTime) == "" {
return domain.CreateTransactionRequest{}, errors.New("datetime is required")
}
dateTime, err := time.Parse(time.RFC3339, *req.DateTime)
if err != nil {
return domain.CreateTransactionRequest{}, errors.New("datetime must be RFC3339")
}
return domain.CreateTransactionRequest{
FamilyID: *req.FamilyID,
Description: req.Description,
Type: strings.TrimSpace(*req.Type),
DateTime: dateTime,
Category: strings.TrimSpace(*req.Category),
Amount: *req.Amount,
CreatedBy: *req.CreatedBy,
ReceiptID: req.ReceiptID,
}, nil
}
func BuildReceiptTransactionRequest(req dto.CreateTransactionRequest) (domain.AddReceiptRequest, error) {
if req.ReceiptNumber == nil || strings.TrimSpace(*req.ReceiptNumber) == "" {
return domain.AddReceiptRequest{}, errors.New("receipt_number is required")
}
if req.ReceiptDate == nil || strings.TrimSpace(*req.ReceiptDate) == "" {
return domain.AddReceiptRequest{}, errors.New("receipt_date is required")
}
if req.FamilyID == nil || req.CreatedBy == nil {
return domain.AddReceiptRequest{}, errors.New("family_id and created_by are required")
}
if req.Amount != nil || req.DateTime != nil || req.ReceiptID != nil {
return domain.AddReceiptRequest{}, errors.New("manual transaction fields cannot be combined with receipt input")
}
isoDate, err := utils.NormalizeDateToISO(strings.TrimSpace(*req.ReceiptDate))
if err != nil {
return domain.AddReceiptRequest{}, errors.New("invalid receipt_date format")
}
return domain.AddReceiptRequest{
Number: strings.TrimSpace(*req.ReceiptNumber),
Date: isoDate,
FamilyID: req.FamilyID,
CreatedBy: req.CreatedBy,
Type: trimOptionalString(req.Type),
Category: trimOptionalString(req.Category),
Description: trimOptionalString(req.Description),
}, nil
}
func trimOptionalString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
+56
View File
@@ -0,0 +1,56 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"net/http"
"github.com/gin-gonic/gin"
)
type ActivitiesRouter struct {
service services.ActivityService
}
func NewActivitiesRouter(s services.ActivityService) *ActivitiesRouter {
return &ActivitiesRouter{service: s}
}
func (router *ActivitiesRouter) RegisterRoutes(r *gin.RouterGroup) {
activities := r.Group("/activities")
{
activities.GET("", router.List)
}
}
// List GoDoc
// @Summary Получить активность пользователей
// @Description Возвращает список действий пользователей с пагинацией
// @Tags Activities
// @Accept json
// @Produce json
// @Param family_id query int false "Family ID"
// @Param user_id query int false "User ID"
// @Param limit query int false "Limit, default 10"
// @Param offset query int false "Offset"
// @Success 200 {object} dto.ActivityListResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/activities [get]
func (router *ActivitiesRouter) List(c *gin.Context) {
var query dto.ActivityListQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
filter := requests.BuildActivityListFilter(query)
activities, filter, err := router.service.List(c.Request.Context(), filter)
if err != nil {
handleActivityError(c, err)
return
}
c.JSON(http.StatusOK, dto.ActivitiesToListResponse(activities, filter.Limit, filter.Offset))
}
@@ -0,0 +1,65 @@
package routers
import (
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type activityServiceMock struct {
listFn func(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error)
}
func (m *activityServiceMock) List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) {
if m.listFn != nil {
return m.listFn(ctx, filter)
}
return nil, filter, errors.New("mock list is not configured")
}
func setupActivitiesRouter(mock services.ActivityService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
apiV1 := r.Group("/api/v1")
router := NewActivitiesRouter(mock)
router.RegisterRoutes(apiV1)
return r
}
func TestActivitiesRouter_List(t *testing.T) {
t.Run("uses default pagination", func(t *testing.T) {
r := setupActivitiesRouter(&activityServiceMock{listFn: func(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) {
assert.Equal(t, 0, filter.Limit)
assert.Equal(t, 0, filter.Offset)
activity := &domain.ActivityLog{
ID: 1,
UserID: 2,
Action: "create",
EntityType: "transaction",
Description: "Created transaction 1",
CreatedAt: time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC),
}
filter.Limit = 10
return []*domain.ActivityLog{activity}, filter, nil
}})
req := httptest.NewRequest(http.MethodGet, "/api/v1/activities", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "\"limit\":10")
assert.Contains(t, w.Body.String(), "Created transaction 1")
})
}
+26
View File
@@ -0,0 +1,26 @@
package routers
import (
"FamilyHub/src/api/services"
"github.com/gin-gonic/gin"
)
type AuthRouter struct {
service services.AuthService
}
func NewAuthRouter(s services.AuthService) *AuthRouter {
return &AuthRouter{service: s}
}
func (router *AuthRouter) RegisterRouter(r *gin.RouterGroup) {
auth := r.Group("/auth")
{
auth.POST("", router.Auth)
}
}
func (router *AuthRouter) Auth(c *gin.Context) {
}
+98
View File
@@ -0,0 +1,98 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider"
"database/sql"
"errors"
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
func logError(c *gin.Context, scope string, err error) {
log.Printf(
"%s failed: method=%s path=%s route=%s error=%v",
scope,
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
)
}
func logInternalError(c *gin.Context, scope string, err error) {
log.Printf(
"%s failed: method=%s path=%s route=%s error=%v\n%s",
scope,
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
debug.Stack(),
)
}
func handleReceiptError(c *gin.Context, err error) {
var externalErr *receiptServiceIntegration.ExternalServiceError
switch {
case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.As(err, &externalErr):
log.Printf(
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
c.Request.Method,
c.Request.URL.Path,
externalErr.StatusCode,
externalErr.Body,
)
logError(c, "receipt external service", err)
switch externalErr.StatusCode {
case http.StatusForbidden, http.StatusTooManyRequests:
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"})
default:
c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"})
}
default:
logInternalError(c, "receipt request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func handleActivityError(c *gin.Context, err error) {
logInternalError(c, "activity request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
func handleFamilyError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrFamilyNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, sql.ErrNoRows):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: "family not found"})
case errors.Is(err, requests.ErrFamilyNameRequired):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
default:
logInternalError(c, "family request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func handleUserError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrUserNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrInvalidPatch):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTelegramIDMissing):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
default:
logInternalError(c, "user request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
@@ -2,11 +2,10 @@ package routers
import ( import (
"FamilyHub/src/api/dto" "FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services" "FamilyHub/src/api/services"
"database/sql" "FamilyHub/src/domain"
"errors"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -23,7 +22,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
families := r.Group("/families") families := r.Group("/families")
{ {
families.POST("", router.Create) families.POST("", router.Create)
families.GET("/:id", router.GetByID) families.GET("/:id", router.Read)
families.PATCH("/:id", router.Update) families.PATCH("/:id", router.Update)
families.DELETE("/:id", router.Delete) families.DELETE("/:id", router.Delete)
} }
@@ -35,17 +34,17 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Tags Families // @Tags Families
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param family body dto.CreateFamilyRequest true "Family info" // @Param family body domain.CreateFamilyRequest true "Family info"
// @Success 201 {object} dto.FamilyResponse // @Success 201 {object} domain.FamilyResponse
// @Failure 400 {object} map[string]string "invalid body" // @Failure 400 {object} dto.ErrorResponse "invalid body"
// @Failure 500 {object} map[string]string "internal server error" // @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /families [post] // @Router /api/v1/families [post]
func (router *FamiliesRouter) Create(c *gin.Context) { func (router *FamiliesRouter) Create(c *gin.Context) {
var req dto.CreateFamilyRequest var req domain.CreateFamilyRequest
var resp dto.FamilyResponse var resp domain.FamilyResponse
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
@@ -58,24 +57,24 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
c.JSON(http.StatusCreated, resp.ModelToResponse(family)) c.JSON(http.StatusCreated, resp.ModelToResponse(family))
} }
// GetByID GoDoc // Read GoDoc
// @Summary Получить семью по ID // @Summary Получить семью по ID
// @Description Возвращает семью по ее внутреннему ID // @Description Возвращает семью по ее внутреннему ID
// @Tags Families // @Tags Families
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path int true "Family ID" // @Param id path int true "Family ID"
// @Success 200 {object} dto.FamilyResponse // @Success 200 {object} domain.FamilyResponse
// @Failure 400 {object} map[string]string "invalid id" // @Failure 400 {object} dto.ErrorResponse "invalid id"
// @Failure 404 {object} map[string]string "family not found" // @Failure 404 {object} dto.ErrorResponse "family not found"
// @Failure 500 {object} map[string]string "internal server error" // @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /families/{id} [get] // @Router /api/v1/families/{id} [get]
func (router *FamiliesRouter) GetByID(c *gin.Context) { func (router *FamiliesRouter) Read(c *gin.Context) {
var resp dto.FamilyResponse var resp domain.FamilyResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := requests.ParseInt64(c.Param("id"), "invalid id")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
@@ -95,29 +94,29 @@ func (router *FamiliesRouter) GetByID(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path int true "Family ID" // @Param id path int true "Family ID"
// @Param family body dto.UpdateFamilyRequest true "Данные для обновления" // @Param family body domain.UpdateFamilyRequest true "Данные для обновления"
// @Success 200 {object} dto.FamilyResponse // @Success 200 {object} domain.FamilyResponse
// @Failure 400 {object} map[string]string "invalid id or invalid body" // @Failure 400 {object} dto.ErrorResponse "invalid id or invalid body"
// @Failure 400 {object} map[string]string "name is required" // @Failure 400 {object} dto.ErrorResponse "name is required"
// @Failure 404 {object} map[string]string "family not found" // @Failure 404 {object} dto.ErrorResponse "family not found"
// @Failure 500 {object} map[string]string "internal server error" // @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /families/{id} [patch] // @Router /api/v1/families/{id} [patch]
func (router *FamiliesRouter) Update(c *gin.Context) { func (router *FamiliesRouter) Update(c *gin.Context) {
var resp dto.FamilyResponse var resp domain.FamilyResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := requests.ParseInt64(c.Param("id"), "invalid id")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
var req dto.UpdateFamilyRequest var req domain.UpdateFamilyRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
if req.Name == nil { if err := requests.ValidateFamilyUpdate(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
@@ -138,14 +137,14 @@ func (router *FamiliesRouter) Update(c *gin.Context) {
// @Produce json // @Produce json
// @Param id path int true "Family ID" // @Param id path int true "Family ID"
// @Success 204 {string} string "no content" // @Success 204 {string} string "no content"
// @Failure 400 {object} map[string]string "invalid id" // @Failure 400 {object} dto.ErrorResponse "invalid id"
// @Failure 404 {object} map[string]string "family not found" // @Failure 404 {object} dto.ErrorResponse "family not found"
// @Failure 500 {object} map[string]string "internal server error" // @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /families/{id} [delete] // @Router /api/v1/families/{id} [delete]
func (router *FamiliesRouter) Delete(c *gin.Context) { func (router *FamiliesRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := requests.ParseInt64(c.Param("id"), "invalid id")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
@@ -156,14 +155,3 @@ func (router *FamiliesRouter) Delete(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func handleFamilyError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrFamilyNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, sql.ErrNoRows):
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}
@@ -1,7 +1,6 @@
package routers package routers
import ( import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/services" "FamilyHub/src/api/services"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"bytes" "bytes"
@@ -20,14 +19,22 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func int64Ptr(v int64) *int64 {
return &v
}
func stringPtr(v string) *string {
return &v
}
type familyServiceMock struct { type familyServiceMock struct {
createFn func(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) createFn func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error)
getByIDFn func(ctx context.Context, id int64) (*domain.Family, error) getByIDFn func(ctx context.Context, id int64) (*domain.Family, error)
updateFn func(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) updateFn func(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error)
deleteFn func(ctx context.Context, id int64) error deleteFn func(ctx context.Context, id int64) error
} }
func (m *familyServiceMock) Create(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) { func (m *familyServiceMock) Create(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
if m.createFn != nil { if m.createFn != nil {
return m.createFn(ctx, req) return m.createFn(ctx, req)
} }
@@ -41,7 +48,7 @@ func (m *familyServiceMock) GetByID(ctx context.Context, id int64) (*domain.Fami
return nil, errors.New("mock getByID is not configured") return nil, errors.New("mock getByID is not configured")
} }
func (m *familyServiceMock) Update(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) { func (m *familyServiceMock) Update(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
if m.updateFn != nil { if m.updateFn != nil {
return m.updateFn(ctx, id, req) return m.updateFn(ctx, id, req)
} }
@@ -69,8 +76,8 @@ func sampleFamily() *domain.Family {
ID: 7, ID: 7,
Name: "Belan", Name: "Belan",
OwnerID: 10, OwnerID: 10,
TelegramChatID: 12345, TelegramChatID: int64Ptr(12345),
TelegramChatName: "Family Chat", TelegramChatName: stringPtr("Family Chat"),
CreatedAt: time.Date(2026, time.January, 21, 10, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, time.January, 21, 10, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, time.January, 21, 11, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, time.January, 21, 11, 0, 0, 0, time.UTC),
} }
@@ -86,11 +93,11 @@ func TestFamiliesRouter_Create(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "error") assert.Contains(t, w.Body.String(), "message")
}) })
t.Run("internal error", func(t *testing.T) { t.Run("internal error", func(t *testing.T) {
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) { r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
return nil, errors.New("db unavailable") return nil, errors.New("db unavailable")
}}) }})
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`))
@@ -105,11 +112,13 @@ func TestFamiliesRouter_Create(t *testing.T) {
t.Run("created", func(t *testing.T) { t.Run("created", func(t *testing.T) {
expected := sampleFamily() expected := sampleFamily()
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) { r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
assert.Equal(t, "Belan", req.Name) assert.Equal(t, "Belan", req.Name)
assert.Nil(t, req.TelegramChatID)
assert.Nil(t, req.TelegramChatName)
return expected, nil return expected, nil
}}) }})
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10}`))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -196,7 +205,7 @@ func TestFamiliesRouter_Update(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "error") assert.Contains(t, w.Body.String(), "message")
}) })
t.Run("bad request on missing name", func(t *testing.T) { t.Run("bad request on missing name", func(t *testing.T) {
@@ -212,7 +221,7 @@ func TestFamiliesRouter_Update(t *testing.T) {
}) })
t.Run("not found", func(t *testing.T) { t.Run("not found", func(t *testing.T) {
r := setupFamiliesRouter(&familyServiceMock{updateFn: func(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) { r := setupFamiliesRouter(&familyServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
return nil, services.ErrFamilyNotFound return nil, services.ErrFamilyNotFound
}}) }})
name := "Belan Updated" name := "Belan Updated"
@@ -230,11 +239,12 @@ func TestFamiliesRouter_Update(t *testing.T) {
expected := sampleFamily() expected := sampleFamily()
updatedName := "Belan Updated" updatedName := "Belan Updated"
expected.Name = updatedName expected.Name = updatedName
r := setupFamiliesRouter(&familyServiceMock{updateFn: func(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) { r := setupFamiliesRouter(&familyServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
assert.Equal(t, int64(7), id) assert.Equal(t, int64(7), id)
require.NotNil(t, req.Name) require.NotNil(t, req.Name)
assert.Equal(t, updatedName, *req.Name) assert.Equal(t, updatedName, *req.Name)
assert.Equal(t, "Updated", req.TelegramChatName) require.NotNil(t, req.TelegramChatName)
assert.Equal(t, "Updated", *req.TelegramChatName)
return expected, nil return expected, nil
}}) }})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+updatedName+`","telegram_chat_name":"Updated"}`)) req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+updatedName+`","telegram_chat_name":"Updated"}`))
@@ -293,7 +303,7 @@ func TestFamiliesRouter_Delete(t *testing.T) {
func TestFamiliesRouter_Create_ResponseShape(t *testing.T) { func TestFamiliesRouter_Create_ResponseShape(t *testing.T) {
expected := sampleFamily() expected := sampleFamily()
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) { r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
return expected, nil return expected, nil
}}) }})
@@ -303,7 +313,7 @@ func TestFamiliesRouter_Create_ResponseShape(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
var resp dto.FamilyResponse var resp domain.FamilyResponse
err := json.Unmarshal(w.Body.Bytes(), &resp) err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expected.ID, resp.ID) assert.Equal(t, expected.ID, resp.ID)
+411
View File
@@ -0,0 +1,411 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type TransactionsRouter struct {
service services.TransactionService
creationService services.TransactionCreationService
}
func NewTransactionsRouter(
s services.TransactionService,
creationService services.TransactionCreationService,
) *TransactionsRouter {
return &TransactionsRouter{service: s, creationService: creationService}
}
func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
transactions := r.Group("/transactions")
{
transactions.POST("", router.Create)
transactions.GET("", router.List)
transactions.GET("/analytics", router.Analytics)
transactions.GET("/:id", router.Read)
transactions.PATCH("/:id", router.Update)
transactions.DELETE("/:id", router.Delete)
}
}
// Create GoDoc
// @Summary Создать транзакцию
// @Description Создает транзакцию одним из трех способов.
// @Description 1. application/json: ручная транзакция с полями family_id, created_by, type, category, amount, datetime.
// @Description 2. application/json: транзакция по чеку с полями family_id, created_by, receipt_number, receipt_date.
// @Description 3. multipart/form-data: транзакция по фото чека с полями photo, family_id, created_by и опциональными type, category, description.
// @Description В одном JSON-запросе нельзя смешивать ручные поля транзакции с полями receipt_number и receipt_date.
// @Tags Transactions
// @Accept json
// @Accept multipart/form-data
// @Produce json
// @Param transaction body dto.CreateTransactionRequest false "JSON payload for manual or receipt-based transaction creation"
// @Success 201 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/transactions [post]
func (router *TransactionsRouter) Create(c *gin.Context) {
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
router.createFromMultipart(c)
return
}
var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
input, err := requests.BuildCreateTransactionInput(req)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
}
func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
fileHeader, err := c.FormFile("photo")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
return
}
file, err := fileHeader.Open()
if err != nil {
logInternalError(c, "transaction upload", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
return
}
defer file.Close()
imageBytes, err := io.ReadAll(file)
if err != nil {
logInternalError(c, "transaction upload", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
return
}
familyID, err := parseOptionalInt64Form(c, "family_id")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
createdBy, err := parseOptionalInt64Form(c, "created_by")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
input, err := requests.BuildPhotoCreateTransactionInput(requests.PhotoCreateTransactionFields{
Image: imageBytes,
FamilyID: familyID,
CreatedBy: createdBy,
Type: parseOptionalStringForm(c, "type"),
Category: parseOptionalStringForm(c, "category"),
Description: parseOptionalStringForm(c, "description"),
})
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
}
// List GoDoc
// @Summary Получить список транзакций
// @Description Возвращает список транзакций с фильтрами и пагинацией
// @Tags Transactions
// @Accept json
// @Produce json
// @Param family_id query int false "Family ID"
// @Param created_by query int false "User ID"
// @Param type query string false "Transaction type"
// @Param category query string false "Category"
// @Param date_from query string false "RFC3339 start datetime"
// @Param date_to query string false "RFC3339 end datetime"
// @Param limit query int false "Limit, default 50"
// @Param offset query int false "Offset"
// @Success 200 {object} dto.TransactionListResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/transactions [get]
func (router *TransactionsRouter) List(c *gin.Context) {
var query dto.ListTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
filter, err := transactionQueryToFilter(query)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transactions, err := router.service.List(c.Request.Context(), filter)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionsToListResponse(transactions))
}
// Analytics GoDoc
// @Summary Получить аналитику по транзакциям
// @Description Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.
// @Tags Transactions
// @Accept json
// @Produce json
// @Param family_id query int false "Family ID"
// @Param type query string false "Transaction type: income or expense"
// @Param date_from query string true "RFC3339 start datetime"
// @Param date_to query string true "RFC3339 end datetime"
// @Success 200 {object} dto.TransactionAnalyticsResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/transactions/analytics [get]
func (router *TransactionsRouter) Analytics(c *gin.Context) {
var query dto.TransactionAnalyticsQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
dateFrom, err := time.Parse(time.RFC3339, query.DateFrom)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "date_from must be RFC3339"})
return
}
dateTo, err := time.Parse(time.RFC3339, query.DateTo)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "date_to must be RFC3339"})
return
}
analytics, err := router.service.Analytics(c.Request.Context(), domain.TransactionAnalyticsFilter{
FamilyID: query.FamilyID,
Type: query.Type,
DateFrom: dateFrom,
DateTo: dateTo,
})
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionAnalyticsToResponse(analytics))
}
// Read GoDoc
// @Summary Получить транзакцию по ID
// @Description Возвращает транзакцию по ее внутреннему ID
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Success 200 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/transactions/{id} [get]
func (router *TransactionsRouter) Read(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
transaction, err := router.service.GetByID(c.Request.Context(), id)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionToResponse(transaction))
}
// Update GoDoc
// @Summary Обновить транзакцию
// @Description Частично обновляет данные транзакции и связь с чеком
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Param transaction body dto.UpdateTransactionRequest true "Transaction patch payload"
// @Success 200 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/transactions/{id} [patch]
func (router *TransactionsRouter) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
var req dto.UpdateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
var dateTime *time.Time
if req.DateTime != nil {
parsed, err := time.Parse(time.RFC3339, *req.DateTime)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
return
}
dateTime = &parsed
}
transaction, err := router.service.Update(c.Request.Context(), id, domain.UpdateTransactionRequest{
Description: req.Description,
Type: req.Type,
DateTime: dateTime,
Category: req.Category,
Amount: req.Amount,
ReceiptID: req.ReceiptID,
DetachReceipt: req.DetachReceipt,
})
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionToResponse(transaction))
}
// Delete GoDoc
// @Summary Удалить транзакцию
// @Description Удаляет транзакцию по ID
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Success 204 {string} string "no content"
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/transactions/{id} [delete]
func (router *TransactionsRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
if err := router.service.Delete(c.Request.Context(), id); err != nil {
handleTransactionError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func handleTransactionError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction),
errors.Is(err, services.ErrInvalidAnalytics),
errors.Is(err, services.ErrInvalidTransactionCreateInput),
errors.Is(err, services.ErrReceiptTransactionActorsMissing),
errors.Is(err, services.ErrOCRTextNotFound),
errors.Is(err, services.ErrReceiptNumberNotFound),
errors.Is(err, services.ErrReceiptDateNotFound):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptServiceNotConfigured),
errors.Is(err, services.ErrOCRNotConfigured),
errors.Is(err, services.ErrReceiptTransactionNotCreated):
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
default:
logInternalError(c, "transaction request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func transactionQueryToFilter(query dto.ListTransactionsQuery) (domain.TransactionListFilter, error) {
filter := domain.TransactionListFilter{
FamilyID: query.FamilyID,
CreatedBy: query.CreatedBy,
Type: query.Type,
Category: query.Category,
Limit: query.Limit,
Offset: query.Offset,
}
if query.DateFrom != nil {
parsed, err := time.Parse(time.RFC3339, *query.DateFrom)
if err != nil {
return domain.TransactionListFilter{}, errors.New("date_from must be RFC3339")
}
filter.DateFrom = &parsed
}
if query.DateTo != nil {
parsed, err := time.Parse(time.RFC3339, *query.DateTo)
if err != nil {
return domain.TransactionListFilter{}, errors.New("date_to must be RFC3339")
}
filter.DateTo = &parsed
}
return filter, nil
}
func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) {
value := strings.TrimSpace(c.PostForm(key))
if value == "" {
return nil, nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, errors.New(key + " must be int64")
}
return &parsed, nil
}
func parseOptionalStringForm(c *gin.Context, key string) *string {
value := strings.TrimSpace(c.PostForm(key))
if value == "" {
return nil
}
return &value
}
@@ -0,0 +1,317 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type transactionServiceMock struct {
createFn func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error)
getByIDFn func(ctx context.Context, id int64) (*domain.Transaction, error)
}
type receiptServiceMock struct {
getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
func (m *receiptServiceMock) AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
if m.getReceiptFn != nil {
return m.getReceiptFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
type ocrMock struct {
recognizeFn func(ctx context.Context, image []byte) (string, error)
}
func (m *ocrMock) Recognize(ctx context.Context, image []byte) (string, error) {
if m.recognizeFn != nil {
return m.recognizeFn(ctx, image)
}
return "", errors.New("mock is not configured")
}
func (m *ocrMock) Close() error {
return nil
}
func (m *transactionServiceMock) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
if m.createFn != nil {
return m.createFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
func (m *transactionServiceMock) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
if m.getByIDFn != nil {
return m.getByIDFn(ctx, id)
}
return nil, errors.New("mock is not configured")
}
func (m *transactionServiceMock) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
return nil, errors.New("not implemented")
}
func (m *transactionServiceMock) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) {
return domain.TransactionAnalytics{}, errors.New("not implemented")
}
func (m *transactionServiceMock) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) {
return nil, errors.New("not implemented")
}
func (m *transactionServiceMock) Delete(ctx context.Context, id int64) error {
return errors.New("not implemented")
}
func TestTransactionsRouter_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
validNumber := strings.Repeat("1", 24)
newMultipartRequest := func(t *testing.T, fields map[string]string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("photo", "receipt.jpg")
require.NoError(t, err)
_, err = part.Write([]byte("fake-image"))
require.NoError(t, err)
for key, value := range fields {
require.NoError(t, writer.WriteField(key, value))
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
t.Run("creates manual transaction from json", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
service := &transactionServiceMock{createFn: func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
assert.Equal(t, int64(1), req.FamilyID)
assert.Equal(t, int64(2), req.CreatedBy)
assert.Equal(t, "expense", req.Type)
assert.Equal(t, "groceries", req.Category)
assert.Equal(t, 150.5, req.Amount)
assert.Equal(t, now, req.DateTime)
return &domain.Transaction{
ID: 11,
FamilyID: req.FamilyID,
Type: req.Type,
DateTime: req.DateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
CreatedAt: now,
}, nil
}}
creationService := services.NewTransactionCreationService(service, nil, nil)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":2,
"type":"expense",
"category":"groceries",
"amount":150.5,
"datetime":"2026-01-21T10:11:12Z"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":11`)
})
t.Run("creates transaction from receipt number and date", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "2026-01-21", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(2), *req.CreatedBy)
return &domain.Receipt{ID: 7, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(21)}, nil
}}
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(21), id)
return &domain.Transaction{
ID: 21,
FamilyID: 1,
Type: "expense",
DateTime: now,
Category: "receipt",
Amount: 99.9,
CreatedBy: 2,
CreatedAt: now,
ReceiptID: ptrInt64(7),
}, nil
}}
creationService := services.NewTransactionCreationService(service, receiptSvc, nil)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":2,
"receipt_number":"`+validNumber+`",
"receipt_date":"21.01.2026"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":21`)
})
t.Run("creates transaction from photo upload", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
assert.Equal(t, []byte("fake-image"), image)
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "21.01.2026", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(2), *req.CreatedBy)
return &domain.Receipt{ID: 8, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(22)}, nil
}}
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(22), id)
return &domain.Transaction{
ID: 22,
FamilyID: 1,
Type: "expense",
DateTime: now,
Category: "receipt",
Amount: 123.4,
CreatedBy: 2,
CreatedAt: now,
ReceiptID: ptrInt64(8),
}, nil
}}
creationService := services.NewTransactionCreationService(service, receiptSvc, ocrSvc)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":22`)
})
t.Run("rejects mixed manual and receipt payload", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, &receiptServiceMock{}, nil)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":2,
"receipt_number":"`+validNumber+`",
"receipt_date":"21.01.2026",
"amount":10
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "manual transaction fields cannot be combined with receipt input")
})
t.Run("returns validation error when photo flow misses family data", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, &receiptServiceMock{}, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction")
})
}
func ptrInt64(v int64) *int64 {
return &v
}
func TestBuildManualTransactionRequest(t *testing.T) {
dateTime := "2026-01-21T10:11:12Z"
typeValue := "expense"
category := "groceries"
amount := 12.5
familyID := int64(1)
createdBy := int64(2)
req, err := requests.BuildManualTransactionRequest(dto.CreateTransactionRequest{
FamilyID: &familyID,
CreatedBy: &createdBy,
Type: &typeValue,
Category: &category,
Amount: &amount,
DateTime: &dateTime,
})
require.NoError(t, err)
assert.Equal(t, familyID, req.FamilyID)
}
+179
View File
@@ -0,0 +1,179 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"net/http"
"github.com/gin-gonic/gin"
)
type UsersRouter struct {
service services.UserService
}
func NewUsersRouter(s services.UserService) *UsersRouter {
return &UsersRouter{service: s}
}
func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
users := r.Group("/users")
{
users.POST("", router.Create)
users.GET("/:id", router.Read)
users.PATCH("/:id", router.Update)
users.DELETE("/:id", router.Delete)
users.GET("/by-telegram/:telegramId", router.GetByTelegramID)
}
}
// Create GoDoc
// @Summary Создать пользователя
// @Tags Users
// @Accept json
// @Produce json
// @Param user body domain.CreateUserRequest true "User info"
// @Success 201 {object} domain.UserResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/users [post]
func (router *UsersRouter) Create(c *gin.Context) {
var req domain.CreateUserRequest
var resp domain.UserResponse
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
user, err := router.service.Create(c.Request.Context(), req)
if err != nil {
handleUserError(c, err)
return
}
c.JSON(http.StatusCreated, resp.ModelToResponse(user))
}
// Read GoDoc
// @Summary Получить пользователя по ID
// @Description Возвращает пользователя по его внутреннему ID
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} domain.UserResponse
// @Failure 400 {object} dto.ErrorResponse "invalid id"
// @Failure 404 {object} dto.ErrorResponse "user not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/users/{id} [get]
func (router *UsersRouter) Read(c *gin.Context) {
var resp domain.UserResponse
id, err := requests.ParseInt64(c.Param("id"), "invalid id")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
user, err := router.service.GetByID(c.Request.Context(), id)
if err != nil {
handleUserError(c, err)
return
}
c.JSON(http.StatusOK, resp.ModelToResponse(user))
}
// GetByTelegramID GoDoc
// @Summary Получить пользователя по Telegram ID
// @Description Возвращает пользователя по его Telegram ID
// @Tags Users
// @Accept json
// @Produce json
// @Param telegramId path int true "Telegram ID"
// @Success 200 {object} domain.UserResponse
// @Failure 400 {object} dto.ErrorResponse "invalid telegram id"
// @Failure 404 {object} dto.ErrorResponse "user not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/users/by-telegram/{telegramId} [get]
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
var resp domain.UserResponse
telegramID, err := requests.ParseInt64(c.Param("telegramId"), "invalid telegram id")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
user, err := router.service.GetByTelegramID(c.Request.Context(), telegramID)
if err != nil {
handleUserError(c, err)
return
}
c.JSON(http.StatusOK, resp.ModelToResponse(user))
}
// Update GoDoc
// @Summary Обновить пользователя
// @Description Частично обновляет данные пользователя по ID
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Param user body domain.UpdateUserRequest true "Данные для обновления"
// @Success 200 {object} domain.UserResponse
// @Failure 400 {object} dto.ErrorResponse "invalid id or invalid body"
// @Failure 404 {object} dto.ErrorResponse "user not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/users/{id} [patch]
func (router *UsersRouter) Update(c *gin.Context) {
var resp domain.UserResponse
id, err := requests.ParseInt64(c.Param("id"), "invalid id")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
var req domain.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
user, err := router.service.Update(c.Request.Context(), id, req)
if err != nil {
handleUserError(c, err)
return
}
c.JSON(http.StatusOK, resp.ModelToResponse(user))
}
// Delete GoDoc
// @Summary Удалить пользователя
// @Description Удаляет пользователя по его ID
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 204 {string} string "no content"
// @Failure 400 {object} dto.ErrorResponse "invalid id"
// @Failure 404 {object} dto.ErrorResponse "user not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/users/{id} [delete]
func (router *UsersRouter) Delete(c *gin.Context) {
id, err := requests.ParseInt64(c.Param("id"), "invalid id")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
if err := router.service.Delete(c.Request.Context(), id); err != nil {
handleUserError(c, err)
return
}
c.Status(http.StatusNoContent)
}
@@ -1,7 +1,6 @@
package routers package routers
import ( import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/services" "FamilyHub/src/api/services"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"bytes" "bytes"
@@ -21,35 +20,35 @@ import (
) )
type userServiceMock struct { type userServiceMock struct {
createFn func(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) createFn func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error)
getByIDFn func(ctx context.Context, id int64) (*domain.User, error) getByIDFn func(ctx context.Context, id int64) (*domain.UserModel, error)
getByTelegramIDFn func(ctx context.Context, telegramID int64) (*domain.User, error) getByTelegramIDFn func(ctx context.Context, telegramID int64) (*domain.UserModel, error)
updateFn func(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) updateFn func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error)
deleteFn func(ctx context.Context, id int64) error deleteFn func(ctx context.Context, id int64) error
} }
func (m *userServiceMock) Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) { func (m *userServiceMock) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
if m.createFn != nil { if m.createFn != nil {
return m.createFn(ctx, req) return m.createFn(ctx, req)
} }
return nil, errors.New("mock create is not configured") return nil, errors.New("mock create is not configured")
} }
func (m *userServiceMock) GetByID(ctx context.Context, id int64) (*domain.User, error) { func (m *userServiceMock) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
if m.getByIDFn != nil { if m.getByIDFn != nil {
return m.getByIDFn(ctx, id) return m.getByIDFn(ctx, id)
} }
return nil, errors.New("mock getByID is not configured") return nil, errors.New("mock getByID is not configured")
} }
func (m *userServiceMock) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) { func (m *userServiceMock) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
if m.getByTelegramIDFn != nil { if m.getByTelegramIDFn != nil {
return m.getByTelegramIDFn(ctx, telegramID) return m.getByTelegramIDFn(ctx, telegramID)
} }
return nil, errors.New("mock getByTelegramID is not configured") return nil, errors.New("mock getByTelegramID is not configured")
} }
func (m *userServiceMock) Update(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) { func (m *userServiceMock) Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
if m.updateFn != nil { if m.updateFn != nil {
return m.updateFn(ctx, id, req) return m.updateFn(ctx, id, req)
} }
@@ -72,16 +71,17 @@ func setupUsersRouter(mock services.UserService) *gin.Engine {
return r return r
} }
func sampleUser() *domain.User { func sampleUser() *domain.UserModel {
username := "john" username := "john"
firstName := "John"
lastName := "Doe" lastName := "Doe"
languageCode := "en" languageCode := "en"
return &domain.User{ return &domain.UserModel{
ID: 10, ID: 10,
TelegramID: 100500, TelegramID: 100500,
Username: &username, Username: &username,
FirstName: "John", FirstName: &firstName,
LastName: &lastName, LastName: &lastName,
LanguageCode: &languageCode, LanguageCode: &languageCode,
CreatedAt: time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC), CreatedAt: time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC),
@@ -99,11 +99,11 @@ func TestUsersRouter_CreateUser(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "error") assert.Contains(t, w.Body.String(), "message")
}) })
t.Run("bad request on domain validation error", func(t *testing.T) { t.Run("bad request on domain validation error", func(t *testing.T) {
r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
return nil, services.ErrTelegramIDMissing return nil, services.ErrTelegramIDMissing
}}) }})
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":1,"first_name":"A"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":1,"first_name":"A"}`))
@@ -118,9 +118,10 @@ func TestUsersRouter_CreateUser(t *testing.T) {
t.Run("created", func(t *testing.T) { t.Run("created", func(t *testing.T) {
expected := sampleUser() expected := sampleUser()
r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
assert.Equal(t, int64(100500), req.TelegramID) assert.Equal(t, int64(100500), req.TelegramID)
assert.Equal(t, "John", req.FirstName) require.NotNil(t, req.FirstName)
assert.Equal(t, "John", *req.FirstName)
return expected, nil return expected, nil
}}) }})
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":100500,"first_name":"John"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":100500,"first_name":"John"}`))
@@ -148,7 +149,7 @@ func TestUsersRouter_GetByID(t *testing.T) {
}) })
t.Run("not found", func(t *testing.T) { t.Run("not found", func(t *testing.T) {
r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.UserModel, error) {
return nil, services.ErrUserNotFound return nil, services.ErrUserNotFound
}}) }})
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/1", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/users/1", nil)
@@ -162,7 +163,7 @@ func TestUsersRouter_GetByID(t *testing.T) {
t.Run("ok", func(t *testing.T) { t.Run("ok", func(t *testing.T) {
expected := sampleUser() expected := sampleUser()
r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.UserModel, error) {
assert.Equal(t, int64(10), id) assert.Equal(t, int64(10), id)
return expected, nil return expected, nil
}}) }})
@@ -190,7 +191,7 @@ func TestUsersRouter_GetByTelegramID(t *testing.T) {
t.Run("ok", func(t *testing.T) { t.Run("ok", func(t *testing.T) {
expected := sampleUser() expected := sampleUser()
r := setupUsersRouter(&userServiceMock{getByTelegramIDFn: func(ctx context.Context, telegramID int64) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{getByTelegramIDFn: func(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
assert.Equal(t, int64(100500), telegramID) assert.Equal(t, int64(100500), telegramID)
return expected, nil return expected, nil
}}) }})
@@ -226,11 +227,11 @@ func TestUsersRouter_Update(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "error") assert.Contains(t, w.Body.String(), "message")
}) })
t.Run("bad request on invalid patch", func(t *testing.T) { t.Run("bad request on invalid patch", func(t *testing.T) {
r := setupUsersRouter(&userServiceMock{updateFn: func(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
return nil, services.ErrInvalidPatch return nil, services.ErrInvalidPatch
}}) }})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/10", bytes.NewBufferString(`{"first_name":"John"}`)) req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/10", bytes.NewBufferString(`{"first_name":"John"}`))
@@ -245,7 +246,7 @@ func TestUsersRouter_Update(t *testing.T) {
t.Run("ok", func(t *testing.T) { t.Run("ok", func(t *testing.T) {
expected := sampleUser() expected := sampleUser()
r := setupUsersRouter(&userServiceMock{updateFn: func(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
assert.Equal(t, int64(10), id) assert.Equal(t, int64(10), id)
require.NotNil(t, req.FirstName) require.NotNil(t, req.FirstName)
assert.Equal(t, "John", *req.FirstName) assert.Equal(t, "John", *req.FirstName)
@@ -307,7 +308,7 @@ func TestUsersRouter_Delete(t *testing.T) {
func TestUsersRouter_CreateUser_ResponseShape(t *testing.T) { func TestUsersRouter_CreateUser_ResponseShape(t *testing.T) {
expected := sampleUser() expected := sampleUser()
r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
return expected, nil return expected, nil
}}) }})
@@ -317,7 +318,7 @@ func TestUsersRouter_CreateUser_ResponseShape(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
var resp dto.UserResponse var resp domain.UserResponse
err := json.Unmarshal(w.Body.Bytes(), &resp) err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expected.ID, resp.ID) assert.Equal(t, expected.ID, resp.ID)
@@ -328,7 +329,7 @@ func TestUsersRouter_CreateUser_ResponseShape(t *testing.T) {
} }
func TestUsersRouter_GetByID_UsesPathID(t *testing.T) { func TestUsersRouter_GetByID_UsesPathID(t *testing.T) {
r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.User, error) { r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.UserModel, error) {
assert.Equal(t, int64(42), id) assert.Equal(t, int64(42), id)
u := sampleUser() u := sampleUser()
u.ID = id u.ID = id
+149
View File
@@ -0,0 +1,149 @@
package api
import (
_ "FamilyHub/src/api/docs"
"FamilyHub/src/api/routers"
"FamilyHub/src/api/services"
"FamilyHub/src/config"
"FamilyHub/src/database"
"FamilyHub/src/integrations/ocr"
"FamilyHub/src/integrations/receiptProvider"
"FamilyHub/src/repositories"
"context"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
type Server struct {
httpServer *http.Server
ocr ocr.OCR
}
func NewServer(cfg config.Config) *Server {
dbManager := &database.Database{
ConnectionString: cfg.DBConnectionString,
MigrationsPath: "file://migrations",
}
dbConn, err := dbManager.Connect()
if err != nil {
log.Fatal(err)
}
if err := dbManager.RunMigrations(dbConn); err != nil {
log.Fatal(err)
}
if !cfg.DebugMode {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Logger())
//router.Use(requestLoggingMiddleware())
router.Use(gin.RecoveryWithWriter(os.Stderr))
if cfg.OpenAPIEnabled {
openAPIEndpoint := cfg.OpenAPIEndpoint
if openAPIEndpoint == "" {
openAPIEndpoint = "/openapi"
}
swaggerHandler := ginSwagger.WrapHandler(swaggerFiles.Handler)
serveSwaggerIndex := func(c *gin.Context) {
recorder := httptest.NewRecorder()
proxyCtx, _ := gin.CreateTestContext(recorder)
proxyCtx.Request = c.Request.Clone(c.Request.Context())
proxyCtx.Request.URL.Path = openAPIEndpoint + "/index.html"
proxyCtx.Request.RequestURI = openAPIEndpoint + "/index.html"
swaggerHandler(proxyCtx)
for key, values := range recorder.Header() {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
body := strings.Replace(
recorder.Body.String(),
"<head>",
"<head><base href=\""+openAPIEndpoint+"/\">",
1,
)
c.Status(recorder.Code)
_, _ = c.Writer.WriteString(body)
}
router.GET(openAPIEndpoint, serveSwaggerIndex)
router.GET(openAPIEndpoint+"/*any", func(c *gin.Context) {
if c.Param("any") == "/" {
serveSwaggerIndex(c)
return
}
swaggerHandler(c)
})
}
apiV1 := router.Group("/api/v1")
transactionRepo := repositories.NewTransactionsSQLRepository(dbConn)
activityRepo := repositories.NewActivitySQLRepository(dbConn)
ocrSvc, err := ocr.NewGoogleOCR(context.Background())
if err != nil {
log.Fatal(err)
}
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
receiptProvider_ := receiptProvider.NewReceiptProvider()
receiptService := services.NewReceiptService(receiptProvider_, receiptRepo, transactionRepo)
transactionService := services.NewTransactionService(transactionRepo, activityRepo)
transactionCreationService := services.NewTransactionCreationService(transactionService, receiptService, ocrSvc)
transactionRouter := routers.NewTransactionsRouter(transactionService, transactionCreationService)
transactionRouter.RegisterRoutes(apiV1)
activityService := services.NewActivityService(activityRepo)
activityRouter := routers.NewActivitiesRouter(activityService)
activityRouter.RegisterRoutes(apiV1)
usersRepo := repositories.NewUsersSQLRepository(dbConn)
usersService := services.NewUserService(usersRepo)
usersRouter := routers.NewUsersRouter(usersService)
usersRouter.RegisterRoutes(apiV1)
familyRepo := repositories.NewFamilySQLRepository(dbConn)
familyService := services.NewFamilyService(familyRepo)
familyRouter := routers.NewFamiliesRouter(familyService)
familyRouter.RegisterRoutes(apiV1)
otpRepo := repositories.NewOTPSQLRepository(dbConn)
authService := services.NewAuthService(usersRepo, otpRepo)
authRouter := routers.NewAuthRouter(authService)
authRouter.RegisterRouter(apiV1)
return &Server{
httpServer: &http.Server{
Addr: cfg.APIHost + ":" + cfg.APIPort,
Handler: router,
},
ocr: ocrSvc,
}
}
func (s *Server) Start() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
if s.ocr != nil {
_ = s.ocr.Close()
}
return s.httpServer.Shutdown(ctx)
}
+38
View File
@@ -0,0 +1,38 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"context"
)
type ActivityService interface {
List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error)
}
type activityService struct {
repo repositories.ActivityRepository
}
func NewActivityService(repo repositories.ActivityRepository) ActivityService {
return &activityService{repo: repo}
}
func (s *activityService) List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) {
if filter.Limit <= 0 {
filter.Limit = 10
}
if filter.Limit > 100 {
filter.Limit = 100
}
if filter.Offset < 0 {
filter.Offset = 0
}
activities, err := s.repo.List(ctx, filter)
if err != nil {
return nil, filter, err
}
return activities, filter, nil
}
+131
View File
@@ -0,0 +1,131 @@
package services
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
type AuthService interface {
AuthByTelegram(ctx context.Context, initData string) (string, error)
CreateOTP(ctx context.Context, telegramId int64) error
}
type authService struct {
usersRepo repositories.UsersRepository
otpRepo repositories.OTPRepository
config config.Config
jwt *utils.JWTManager
}
func NewAuthService(usersRepo repositories.UsersRepository, otpRepo repositories.OTPRepository) AuthService {
return &authService{
usersRepo: usersRepo,
otpRepo: otpRepo,
}
}
var (
ErrWrongOtp = errors.New("wrong otp")
ErrForbidden = errors.New("forbidden")
ErrUnauthorized = errors.New("unauthorized")
)
func (s *authService) AuthByTelegram(ctx context.Context, initData string) (string, error) {
data, ok := ValidateTelegramInitData(initData, s.config.BotToken)
if !ok {
return "", ErrUnauthorized
}
var user struct {
ID int64 `json:"id"`
}
err := json.Unmarshal([]byte(data["user"]), &user)
if err != nil {
return "", err
}
userModel, err := s.usersRepo.GetByTelegramID(ctx, user.ID)
if err != nil {
return "", err
}
return s.jwt.Generate(userModel.ID)
}
func (s *authService) CreateOTP(ctx context.Context, telegramId int64) error {
user, err := s.usersRepo.GetByTelegramID(ctx, telegramId)
if err != nil {
return err
}
if user == nil {
return ErrForbidden
}
b := make([]byte, 3)
if _, err = rand.Read(b); err != nil {
return err
}
code := fmt.Sprintf("%06d", (int(b[0])<<16|int(b[1])<<8|int(b[2]))%1000000)
otp := &domain.OTP{
UserID: user.ID,
Code: code,
ExpiredAt: time.Now().Add(10 * time.Minute),
}
err = s.otpRepo.Create(ctx, otp)
if err != nil {
return err
}
return nil
}
func ValidateTelegramInitData(initData string, botToken string) (map[string]string, bool) {
values, err := url.ParseQuery(initData)
if err != nil {
return nil, false
}
hash := values.Get("hash")
values.Del("hash")
var dataCheck []string
for k, v := range values {
dataCheck = append(dataCheck, k+"="+v[0])
}
sort.Strings(dataCheck)
dataCheckString := strings.Join(dataCheck, "\n")
secret := sha256.Sum256([]byte(botToken))
h := hmac.New(sha256.New, secret[:])
h.Write([]byte(dataCheckString))
expectedHash := hex.EncodeToString(h.Sum(nil))
return mapFromValues(values), expectedHash == hash
}
func mapFromValues(v url.Values) map[string]string {
m := make(map[string]string)
for k, val := range v {
m[k] = val[0]
}
return m
}
@@ -1,7 +1,6 @@
package services package services
import ( import (
"FamilyHub/src/api/dto"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"context" "context"
@@ -9,9 +8,9 @@ import (
) )
type FamilyService interface { type FamilyService interface {
Create(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) Create(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error)
GetByID(ctx context.Context, id int64) (*domain.Family, error) GetByID(ctx context.Context, id int64) (*domain.Family, error)
Update(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) Update(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error)
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
} }
@@ -27,7 +26,7 @@ var (
ErrFamilyNotFound = errors.New("family not found") ErrFamilyNotFound = errors.New("family not found")
) )
func (s *familyService) Create(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) { func (s *familyService) Create(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
family_ := &domain.Family{ family_ := &domain.Family{
Name: req.Name, Name: req.Name,
OwnerID: req.OwnerID, OwnerID: req.OwnerID,
@@ -50,7 +49,7 @@ func (s *familyService) GetByID(ctx context.Context, id int64) (*domain.Family,
} }
return family_, nil return family_, nil
} }
func (s *familyService) Update(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) { func (s *familyService) Update(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
existing, err := s.repo.GetByID(ctx, id) existing, err := s.repo.GetByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -62,7 +61,7 @@ func (s *familyService) Update(ctx context.Context, id int64, req dto.UpdateFami
ID: id, ID: id,
Name: *req.Name, Name: *req.Name,
OwnerID: existing.OwnerID, OwnerID: existing.OwnerID,
TelegramChatID: existing.TelegramChatID, TelegramChatID: req.TelegramChatID,
TelegramChatName: req.TelegramChatName, TelegramChatName: req.TelegramChatName,
}); err != nil { }); err != nil {
return nil, err return nil, err
+121
View File
@@ -0,0 +1,121 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/integrations/receiptProvider"
"FamilyHub/src/repositories"
"context"
"fmt"
"strings"
)
type ReceiptService interface {
AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
type receiptService struct {
provider receiptProvider.ReceiptProvider
repo repositories.ReceiptsRepository
transactionRepo repositories.TransactionRepository
}
func NewReceiptService(
provider receiptProvider.ReceiptProvider,
repo repositories.ReceiptsRepository,
transactionRepo repositories.TransactionRepository,
) ReceiptService {
return &receiptService{
provider: provider,
repo: repo,
transactionRepo: transactionRepo,
}
}
func (s *receiptService) AddReceipt(
ctx context.Context,
req domain.AddReceiptRequest,
) (*domain.Receipt, error) {
receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number)
if err != nil {
return nil, err
}
receiptID, err := s.repo.Create(ctx, receipt)
if err != nil {
return nil, err
}
receipt.ID = int(receiptID)
if !s.shouldCreateTransaction(req) {
return receipt, nil
}
transaction, err := s.createTransactionForReceipt(ctx, receipt, req, receiptID)
if err != nil {
if rollbackErr := s.repo.Delete(ctx, receiptID); rollbackErr != nil {
return nil, fmt.Errorf("create receipt transaction: %w (rollback receipt %d: %v)", err, receiptID, rollbackErr)
}
return nil, err
}
receipt.TransactionID = &transaction.ID
return receipt, nil
}
func (s *receiptService) shouldCreateTransaction(req domain.AddReceiptRequest) bool {
return s.transactionRepo != nil && req.FamilyID != nil && req.CreatedBy != nil
}
func (s *receiptService) createTransactionForReceipt(
ctx context.Context,
receipt *domain.Receipt,
req domain.AddReceiptRequest,
receiptID int64,
) (*domain.Transaction, error) {
transactionType := "expense"
if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
transactionType = strings.TrimSpace(*req.Type)
}
category := "receipt"
if req.Category != nil && strings.TrimSpace(*req.Category) != "" {
category = strings.TrimSpace(*req.Category)
}
description := buildReceiptTransactionDescription(receipt, req.Description)
transaction := &domain.Transaction{
FamilyID: *req.FamilyID,
Description: description,
Type: transactionType,
DateTime: receipt.IssuedAt,
Category: category,
Amount: receipt.TotalAmount,
CreatedBy: *req.CreatedBy,
ReceiptID: &receiptID,
}
if err := s.transactionRepo.Create(ctx, transaction); err != nil {
return nil, err
}
return transaction, nil
}
func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *string) *string {
if explicit != nil && strings.TrimSpace(*explicit) != "" {
value := strings.TrimSpace(*explicit)
return &value
}
if name := strings.TrimSpace(receipt.NameSPD); name != "" {
return &name
}
if number := strings.TrimSpace(receipt.ReceiptNumber); number != "" {
value := fmt.Sprintf("Receipt %s", number)
return &value
}
return nil
}
@@ -0,0 +1,147 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/integrations/ocr"
"FamilyHub/src/utils"
"context"
"errors"
"strings"
)
type TransactionCreationService interface {
Create(ctx context.Context, req CreateTransactionInput) (*domain.Transaction, error)
}
type CreateTransactionInput struct {
Manual *domain.CreateTransactionRequest
Receipt *domain.AddReceiptRequest
Photo *CreateTransactionPhotoInput
}
type CreateTransactionPhotoInput struct {
Image []byte
FamilyID *int64
CreatedBy *int64
Type *string
Category *string
Description *string
}
type transactionCreationService struct {
transactionService TransactionService
receiptService ReceiptService
ocr ocr.OCR
}
var (
ErrInvalidTransactionCreateInput = errors.New("exactly one transaction creation mode is required")
ErrReceiptServiceNotConfigured = errors.New("receipt service is not configured")
ErrOCRNotConfigured = errors.New("ocr is not configured")
ErrReceiptTransactionNotCreated = errors.New("transaction was not created for receipt")
ErrReceiptTransactionActorsMissing = errors.New("family_id and created_by are required for receipt transaction")
ErrOCRTextNotFound = errors.New("text not found")
ErrReceiptNumberNotFound = errors.New("receipt number not found")
ErrReceiptDateNotFound = errors.New("receipt date not found")
)
func NewTransactionCreationService(
transactionService TransactionService,
receiptService ReceiptService,
ocrSvc ocr.OCR,
) TransactionCreationService {
return &transactionCreationService{
transactionService: transactionService,
receiptService: receiptService,
ocr: ocrSvc,
}
}
func (s *transactionCreationService) Create(ctx context.Context, req CreateTransactionInput) (*domain.Transaction, error) {
modeCount := 0
if req.Manual != nil {
modeCount++
}
if req.Receipt != nil {
modeCount++
}
if req.Photo != nil {
modeCount++
}
if modeCount != 1 {
return nil, ErrInvalidTransactionCreateInput
}
switch {
case req.Manual != nil:
return s.transactionService.Create(ctx, *req.Manual)
case req.Receipt != nil:
return s.createFromReceipt(ctx, *req.Receipt)
default:
return s.createFromPhoto(ctx, *req.Photo)
}
}
func (s *transactionCreationService) createFromReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Transaction, error) {
if s.receiptService == nil {
return nil, ErrReceiptServiceNotConfigured
}
receipt, err := s.receiptService.AddReceipt(ctx, req)
if err != nil {
return nil, err
}
return s.transactionFromReceipt(ctx, receipt)
}
func (s *transactionCreationService) createFromPhoto(ctx context.Context, req CreateTransactionPhotoInput) (*domain.Transaction, error) {
if s.ocr == nil {
return nil, ErrOCRNotConfigured
}
if s.receiptService == nil {
return nil, ErrReceiptServiceNotConfigured
}
if req.FamilyID == nil || req.CreatedBy == nil {
return nil, ErrReceiptTransactionActorsMissing
}
text, err := s.ocr.Recognize(ctx, req.Image)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return nil, ErrOCRTextNotFound
}
receiptMeta := utils.ExtractReceiptMeta(text)
if receiptMeta.ReceiptID == "" {
return nil, ErrReceiptNumberNotFound
}
if receiptMeta.Date == "" {
return nil, ErrReceiptDateNotFound
}
receipt, err := s.receiptService.AddReceipt(ctx, domain.AddReceiptRequest{
Number: receiptMeta.ReceiptID,
Date: receiptMeta.Date,
FamilyID: req.FamilyID,
CreatedBy: req.CreatedBy,
Type: req.Type,
Category: req.Category,
Description: req.Description,
})
if err != nil {
return nil, err
}
return s.transactionFromReceipt(ctx, receipt)
}
func (s *transactionCreationService) transactionFromReceipt(ctx context.Context, receipt *domain.Receipt) (*domain.Transaction, error) {
if receipt.TransactionID == nil {
return nil, ErrReceiptTransactionNotCreated
}
return s.transactionService.GetByID(ctx, *receipt.TransactionID)
}
@@ -0,0 +1,161 @@
package services
import (
"FamilyHub/src/domain"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type transactionServiceCreateMock struct {
createFn func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error)
getByIDFn func(ctx context.Context, id int64) (*domain.Transaction, error)
}
func (m *transactionServiceCreateMock) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
if m.createFn != nil {
return m.createFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
func (m *transactionServiceCreateMock) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
if m.getByIDFn != nil {
return m.getByIDFn(ctx, id)
}
return nil, errors.New("mock is not configured")
}
func (m *transactionServiceCreateMock) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
return nil, errors.New("not implemented")
}
func (m *transactionServiceCreateMock) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) {
return domain.TransactionAnalytics{}, errors.New("not implemented")
}
func (m *transactionServiceCreateMock) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) {
return nil, errors.New("not implemented")
}
func (m *transactionServiceCreateMock) Delete(ctx context.Context, id int64) error {
return errors.New("not implemented")
}
type receiptServiceCreateMock struct {
addReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
func (m *receiptServiceCreateMock) AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
if m.addReceiptFn != nil {
return m.addReceiptFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
type ocrCreateMock struct {
recognizeFn func(ctx context.Context, image []byte) (string, error)
}
func (m *ocrCreateMock) Recognize(ctx context.Context, image []byte) (string, error) {
if m.recognizeFn != nil {
return m.recognizeFn(ctx, image)
}
return "", errors.New("mock is not configured")
}
func (m *ocrCreateMock) Close() error {
return nil
}
func TestTransactionCreationService_Create_Manual(t *testing.T) {
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
svc := NewTransactionCreationService(
&transactionServiceCreateMock{createFn: func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
assert.Equal(t, int64(1), req.FamilyID)
return &domain.Transaction{ID: 1, FamilyID: req.FamilyID, DateTime: now}, nil
}},
nil,
nil,
)
transaction, err := svc.Create(context.Background(), CreateTransactionInput{
Manual: &domain.CreateTransactionRequest{FamilyID: 1},
})
require.NoError(t, err)
assert.Equal(t, int64(1), transaction.ID)
}
func TestTransactionCreationService_Create_Receipt(t *testing.T) {
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
svc := NewTransactionCreationService(
&transactionServiceCreateMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(15), id)
return &domain.Transaction{ID: id, DateTime: now}, nil
}},
&receiptServiceCreateMock{addReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, "123", req.Number)
return &domain.Receipt{TransactionID: ptrInt64Service(15)}, nil
}},
nil,
)
transaction, err := svc.Create(context.Background(), CreateTransactionInput{
Receipt: &domain.AddReceiptRequest{Number: "123"},
})
require.NoError(t, err)
assert.Equal(t, int64(15), transaction.ID)
}
func TestTransactionCreationService_Create_Photo(t *testing.T) {
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
svc := NewTransactionCreationService(
&transactionServiceCreateMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(17), id)
return &domain.Transaction{ID: id, DateTime: now}, nil
}},
&receiptServiceCreateMock{addReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, strings.Repeat("1", 24), req.Number)
assert.Equal(t, "21.01.2026", req.Date)
return &domain.Receipt{TransactionID: ptrInt64Service(17)}, nil
}},
&ocrCreateMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
assert.Equal(t, []byte("image"), image)
return "21.01.2026 " + strings.Repeat("1", 24), nil
}},
)
familyID := int64(1)
createdBy := int64(2)
transaction, err := svc.Create(context.Background(), CreateTransactionInput{
Photo: &CreateTransactionPhotoInput{
Image: []byte("image"),
FamilyID: &familyID,
CreatedBy: &createdBy,
},
})
require.NoError(t, err)
assert.Equal(t, int64(17), transaction.ID)
}
func TestTransactionCreationService_Create_PhotoRequiresActors(t *testing.T) {
svc := NewTransactionCreationService(
&transactionServiceCreateMock{},
&receiptServiceCreateMock{},
&ocrCreateMock{},
)
_, err := svc.Create(context.Background(), CreateTransactionInput{
Photo: &CreateTransactionPhotoInput{Image: []byte("image")},
})
require.ErrorIs(t, err, ErrReceiptTransactionActorsMissing)
}
func ptrInt64Service(v int64) *int64 {
return &v
}
+205
View File
@@ -0,0 +1,205 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
type TransactionService interface {
Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error)
GetByID(ctx context.Context, id int64) (*domain.Transaction, error)
List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error)
Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error)
Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error)
Delete(ctx context.Context, id int64) error
}
type transactionService struct {
repo repositories.TransactionRepository
activityRepo repositories.ActivityRepository
}
func NewTransactionService(repo repositories.TransactionRepository, activityRepo repositories.ActivityRepository) TransactionService {
return &transactionService{repo: repo, activityRepo: activityRepo}
}
var (
ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found")
ErrInvalidAnalytics = errors.New("type must be income or expense")
)
func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" {
return nil, ErrInvalidTransaction
}
transaction := &domain.Transaction{
FamilyID: req.FamilyID,
Description: req.Description,
Type: req.Type,
DateTime: req.DateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
ReceiptID: req.ReceiptID,
}
if err := s.repo.Create(ctx, transaction); err != nil {
if errors.Is(err, repositories.ErrReceiptNotFound) {
return nil, ErrReceiptNotFound
}
return nil, err
}
if s.activityRepo != nil {
description := fmt.Sprintf("Created transaction %d", transaction.ID)
_ = s.activityRepo.Create(ctx, &domain.ActivityLog{
FamilyID: &transaction.FamilyID,
UserID: transaction.CreatedBy,
Action: "create",
EntityType: "transaction",
EntityID: &transaction.ID,
Description: description,
})
}
return transaction, nil
}
func (s *transactionService) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
transaction, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if transaction == nil {
return nil, ErrTransactionNotFound
}
return transaction, nil
}
func (s *transactionService) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 200 {
filter.Limit = 200
}
if filter.Offset < 0 {
filter.Offset = 0
}
return s.repo.List(ctx, filter)
}
func (s *transactionService) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) {
if filter.Type != nil {
typeValue := strings.TrimSpace(*filter.Type)
if typeValue != "income" && typeValue != "expense" {
return domain.TransactionAnalytics{}, ErrInvalidAnalytics
}
filter.Type = &typeValue
}
return s.repo.Analytics(ctx, filter)
}
func (s *transactionService) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) {
if req.ReceiptID != nil && req.DetachReceipt {
return nil, ErrReceiptLinkConflict
}
existing, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, ErrTransactionNotFound
}
if req.Description == nil &&
req.Type == nil &&
req.DateTime == nil &&
req.Category == nil &&
req.Amount == nil &&
req.ReceiptID == nil &&
!req.DetachReceipt {
return nil, ErrTransactionPatch
}
updated := &domain.Transaction{
ID: existing.ID,
FamilyID: existing.FamilyID,
Description: existing.Description,
Type: existing.Type,
DateTime: existing.DateTime,
Category: existing.Category,
Amount: existing.Amount,
CreatedAt: existing.CreatedAt,
CreatedBy: existing.CreatedBy,
ReceiptID: existing.ReceiptID,
}
if req.Description != nil {
updated.Description = req.Description
}
if req.Type != nil {
updated.Type = *req.Type
}
if req.DateTime != nil {
updated.DateTime = *req.DateTime
}
if req.Category != nil {
updated.Category = *req.Category
}
if req.Amount != nil {
updated.Amount = *req.Amount
}
syncReceipt := false
if req.DetachReceipt {
updated.ReceiptID = nil
syncReceipt = true
}
if req.ReceiptID != nil {
updated.ReceiptID = req.ReceiptID
syncReceipt = true
}
if strings.TrimSpace(updated.Type) == "" || strings.TrimSpace(updated.Category) == "" {
return nil, ErrInvalidTransaction
}
if err := s.repo.Update(ctx, updated, syncReceipt); err != nil {
if errors.Is(err, repositories.ErrReceiptNotFound) {
return nil, ErrReceiptNotFound
}
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTransactionNotFound
}
return nil, err
}
return s.repo.GetByID(ctx, id)
}
func (s *transactionService) Delete(ctx context.Context, id int64) error {
transaction, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if transaction == nil {
return ErrTransactionNotFound
}
return s.repo.Delete(ctx, id)
}
@@ -1,7 +1,6 @@
package services package services
import ( import (
"FamilyHub/src/api/dto"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"context" "context"
@@ -9,10 +8,10 @@ import (
) )
type UserService interface { type UserService interface {
Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error)
GetByID(ctx context.Context, id int64) (*domain.User, error) GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
Update(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error)
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
} }
@@ -30,8 +29,8 @@ var (
ErrTelegramIDMissing = errors.New("telegram_id is required") ErrTelegramIDMissing = errors.New("telegram_id is required")
) )
func (s *userService) Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) { func (s *userService) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
user_ := &domain.User{ user_ := &domain.UserModel{
TelegramID: req.TelegramID, TelegramID: req.TelegramID,
Username: req.Username, Username: req.Username,
FirstName: req.FirstName, FirstName: req.FirstName,
@@ -45,7 +44,7 @@ func (s *userService) Create(ctx context.Context, req dto.CreateUserRequest) (*d
return user_, nil return user_, nil
} }
func (s *userService) GetByID(ctx context.Context, id int64) (*domain.User, error) { func (s *userService) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
user, err := s.repo.GetByID(ctx, id) user, err := s.repo.GetByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -55,7 +54,7 @@ func (s *userService) GetByID(ctx context.Context, id int64) (*domain.User, erro
} }
return user, nil return user, nil
} }
func (s *userService) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) { func (s *userService) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
user, err := s.repo.GetByTelegramID(ctx, telegramID) user, err := s.repo.GetByTelegramID(ctx, telegramID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -66,7 +65,7 @@ func (s *userService) GetByTelegramID(ctx context.Context, telegramID int64) (*d
return user, nil return user, nil
} }
func (s *userService) Update(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) { func (s *userService) Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
existing, err := s.repo.GetByID(ctx, id) existing, err := s.repo.GetByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -82,10 +81,10 @@ func (s *userService) Update(ctx context.Context, id int64, req dto.UpdateUserRe
return nil, ErrInvalidPatch return nil, ErrInvalidPatch
} }
if err := s.repo.Update(ctx, &domain.User{ if err := s.repo.Update(ctx, &domain.UserModel{
ID: id, ID: id,
Username: req.Username, Username: req.Username,
FirstName: *req.FirstName, FirstName: req.FirstName,
LastName: req.LastName, LastName: req.LastName,
LanguageCode: req.LanguageCode, LanguageCode: req.LanguageCode,
}); err != nil { }); err != nil {
+77
View File
@@ -0,0 +1,77 @@
package bot
import (
"FamilyHub/src/bot/handlers"
"FamilyHub/src/config"
"FamilyHub/src/integrations/familyHub"
"FamilyHub/src/integrations/ocr"
"context"
"fmt"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Bot struct {
api *tgbotapi.BotAPI
ocr ocr.OCR
router *Router
}
func NewBot(cfg config.Config) (*Bot, error) {
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
if err != nil {
return nil, fmt.Errorf("create telegram bot api: %w", err)
}
api.Debug = cfg.DebugMode
ctx := context.Background()
ocrSvc, err := ocr.NewGoogleOCR(ctx)
if err != nil {
return nil, fmt.Errorf("create google ocr client: %w", err)
}
apiHost := strings.TrimSpace(cfg.APIHost)
if apiHost == "" {
apiHost = "localhost"
}
apiPort := strings.TrimSpace(cfg.APIPort)
if apiPort == "" {
apiPort = "8000"
}
receiptAPI, err := familyHub.NewApiClient(cfg)
if err != nil {
_ = ocrSvc.Close()
return nil, fmt.Errorf("create family hub api client: %w", err)
}
handler := handlers.New(api, ocrSvc, receiptAPI)
return &Bot{api: api, ocr: ocrSvc, router: NewRouter(handler)}, nil
}
func (bot *Bot) Start(ctx context.Context) error {
u := tgbotapi.NewUpdate(0)
u.Timeout = 1
updates := bot.api.GetUpdatesChan(u)
for {
select {
case <-ctx.Done():
log.Println("Telegram bot stopping...")
bot.api.StopReceivingUpdates()
if bot.ocr != nil {
_ = bot.ocr.Close()
}
time.Sleep(500 * time.Millisecond)
return nil
case update, ok := <-updates:
if !ok {
return nil
}
bot.router.Handle(update)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package handlers
import (
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"context"
"errors"
"fmt"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandleCreateFamily(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
if msg.Chat == nil || msg.Chat.Type != "supergroup" {
h.reply(msg.Chat.ID, "Для создания семьи переведи бота в супергруппу и запусти /createFamily там")
return
}
h.setFamilyState(msg.From.ID, familyCreationState{AwaitingName: true, ChatID: msg.Chat.ID})
h.reply(msg.Chat.ID, "Введи имя семьи одним сообщением")
}
func (h *Handler) handleCreateFamilyName(msg *tgbotapi.Message) {
if msg.From == nil || msg.Chat == nil {
return
}
familyName := strings.TrimSpace(msg.Text)
if familyName == "" {
h.reply(msg.Chat.ID, "Имя семьи не может быть пустым. Введи имя еще раз")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
user, err := h.receiptApi.GetUserByTelegramID(ctx, msg.From.ID)
if err != nil {
if errors.Is(err, services.ErrUserNotFound) {
h.reply(msg.Chat.ID, "Сначала зарегистрируйся: /register")
return
}
log.Printf("failed to get user by telegram id: %v", err)
h.reply(msg.Chat.ID, "Не удалось получить пользователя приложения")
return
}
promoteCfg := tgbotapi.PromoteChatMemberConfig{
ChatMemberConfig: tgbotapi.ChatMemberConfig{
ChatID: msg.Chat.ID,
UserID: msg.From.ID,
},
CanManageChat: true,
CanChangeInfo: true,
CanDeleteMessages: true,
CanManageVoiceChats: true,
CanInviteUsers: true,
CanRestrictMembers: true,
CanPinMessages: true,
}
if _, err := h.api.Request(promoteCfg); err != nil {
log.Printf("failed to promote user to admin: %v", err)
h.reply(msg.Chat.ID, "Не удалось назначить тебя администратором. Проверь права бота")
return
}
chatName := msg.Chat.Title
if strings.TrimSpace(chatName) == "" {
chatName = familyName
}
chatID := msg.Chat.ID
err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{
Name: familyName,
OwnerID: user.ID,
TelegramChatID: &chatID,
TelegramChatName: &chatName,
})
if err != nil {
log.Printf("failed to create family in api: %v", err)
h.reply(msg.Chat.ID, fmt.Sprintf("Не удалось создать семью в API: %v", err))
return
}
h.clearFamilyState(msg.From.ID)
h.reply(msg.Chat.ID, "Семья создана успешно")
}
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
api "FamilyHub/src/integrations/familyHub"
"FamilyHub/src/integrations/ocr"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type registrationState struct {
AgreementOffered bool
AwaitingApproval bool
}
type familyCreationState struct {
AwaitingName bool
ChatID int64
}
type Handler struct {
api *tgbotapi.BotAPI
ocr ocr.OCR
receiptApi api.ApiClient
registrationMu sync.Mutex
registrationState map[int64]registrationState
familyMu sync.Mutex
familyState map[int64]familyCreationState
}
func New(api *tgbotapi.BotAPI, ocrSvc ocr.OCR, receiptClient api.ApiClient) *Handler {
return &Handler{
api: api,
ocr: ocrSvc,
receiptApi: receiptClient,
registrationState: map[int64]registrationState{},
familyState: map[int64]familyCreationState{},
}
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (h *Handler) HandleHelp(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, "Доступные команды:\n/start\n/register\n/termsOfService\n/getAgreement\n/createFamily\n/help")
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"FamilyHub/src/domain"
"FamilyHub/src/utils"
"context"
"io"
"net/http"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandlePhoto(msg *tgbotapi.Message) {
photo := msg.Photo[len(msg.Photo)-1]
file, err := h.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
if err != nil {
h.reply(msg.Chat.ID, "Не смог получить файл 😢")
return
}
url := file.Link(h.api.Token)
resp, err := http.Get(url)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка загрузки изображения")
return
}
defer resp.Body.Close()
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка чтения изображения")
return
}
text, err := h.ocr.Recognize(context.Background(), imageBytes)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка OCR 😢")
return
}
if text == "" {
h.reply(msg.Chat.ID, "Текст не найден")
return
}
receiptMeta := utils.ExtractReceiptMeta(text)
payload := domain.AddReceiptRequest{Number: receiptMeta.ReceiptID, Date: receiptMeta.Date}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
txt, err := utils.DecodeQR(imageBytes)
println(txt)
err = h.receiptApi.SendReceipt(ctx, payload)
reply := "📄 *Результат распознавания*\n\n"
if receiptMeta.Date != "" {
reply += "📅 Дата: " + receiptMeta.Date + "\n"
} else {
reply += "📅 Дата: не найдена\n"
}
if receiptMeta.ReceiptID != "" {
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
} else {
reply += "🧾 Номер чека: не найден\n"
}
if err != nil {
reply += "Не удалось отправить чек в API " + err.Error()
} else {
reply += "Чек добавлен в базу"
}
h.replyMarkdown(msg.Chat.ID, reply)
}
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"FamilyHub/src/domain"
"context"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const agreementConfirmationText = "Я принимаю условия"
const termsOfServiceText = "Лицензионное соглашение:\n" +
"1. Вы подтверждаете согласие на обработку данных.\n" +
"2. Вы соглашаетесь с правилами использования FamilyHUB."
func (h *Handler) HandleRegister(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
registered, err := h.receiptApi.IsUserRegistered(ctx, msg.From.ID)
if err != nil {
log.Printf("failed to check registration: %v", err)
h.reply(msg.Chat.ID, "Не удалось проверить регистрацию. Попробуйте позже.")
return
}
if registered {
h.reply(msg.Chat.ID, "Ты уже зарегистрирован. Доступно: /createFamily, /help, /info")
return
}
h.setRegistrationState(msg.From.ID, registrationState{AgreementOffered: true})
h.reply(msg.Chat.ID, termsOfServiceText+"\n\nЕсли согласен, нажми /getAgreement")
}
func (h *Handler) HandleAgreementConfirmation(msg *tgbotapi.Message) {
if msg.From == nil {
return
}
if !strings.EqualFold(strings.TrimSpace(msg.Text), agreementConfirmationText) {
h.reply(msg.Chat.ID, "Фраза не совпадает. Введи точно: \"Я принимаю условия\"")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := h.receiptApi.RegisterUser(ctx, domain.CreateUserRequest{
TelegramID: msg.From.ID,
Username: stringPtrOrNil(msg.From.UserName),
FirstName: stringPtrOrNil(msg.From.FirstName),
LastName: stringPtrOrNil(msg.From.LastName),
LanguageCode: stringPtrOrNil(msg.From.LanguageCode),
})
if err != nil {
log.Printf("failed to register user: %v", err)
h.reply(msg.Chat.ID, "Не удалось завершить регистрацию. Попробуй позже.")
return
}
h.clearRegistrationState(msg.From.ID)
h.reply(msg.Chat.ID, "Регистрация завершена. Доступно: /createFamily, /help, /info")
}
func (h *Handler) HandleGetAgreement(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
state, ok := h.getRegistrationState(msg.From.ID)
if !ok || !state.AgreementOffered {
h.reply(msg.Chat.ID, "Сначала запусти /register")
return
}
state.AwaitingApproval = true
h.setRegistrationState(msg.From.ID, state)
h.reply(msg.Chat.ID, "Введи фразу для подтверждения: \"Я принимаю условия\"")
}
func (h *Handler) HandleTermsOfService(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, termsOfServiceText)
}
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"log"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) reply(chat int64, text string) {
m := tgbotapi.NewMessage(chat, text)
_, err := h.api.Send(m)
if err != nil {
log.Fatal(err)
}
}
func (h *Handler) replyMarkdown(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
h.api.Send(msg)
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (h *Handler) HandleStart(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, "Привет! Я FamilyHUB-бот. Доступно: /register, /termsOfService, /help")
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
func (h *Handler) setRegistrationState(userID int64, state registrationState) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
h.registrationState[userID] = state
}
func (h *Handler) getRegistrationState(userID int64) (registrationState, bool) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
state, ok := h.registrationState[userID]
return state, ok
}
func (h *Handler) clearRegistrationState(userID int64) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
delete(h.registrationState, userID)
}
func (h *Handler) isAwaitingAgreement(userID int64) bool {
state, ok := h.getRegistrationState(userID)
return ok && state.AwaitingApproval
}
func (h *Handler) setFamilyState(userID int64, state familyCreationState) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
h.familyState[userID] = state
}
func (h *Handler) getFamilyState(userID int64) (familyCreationState, bool) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
state, ok := h.familyState[userID]
return state, ok
}
func (h *Handler) clearFamilyState(userID int64) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
delete(h.familyState, userID)
}
func (h *Handler) isAwaitingFamilyName(userID, chatID int64) bool {
state, ok := h.getFamilyState(userID)
return ok && state.AwaitingName && state.ChatID == chatID
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandleUnknown(msg *tgbotapi.Message) {
if msg.From == nil {
return
}
text := strings.TrimSpace(msg.Text)
if text == "" || strings.HasPrefix(text, "/") {
return
}
if h.isAwaitingAgreement(msg.From.ID) {
h.HandleAgreementConfirmation(msg)
return
}
if msg.Chat != nil && h.isAwaitingFamilyName(msg.From.ID, msg.Chat.ID) {
h.handleCreateFamilyName(msg)
}
}
+9
View File
@@ -0,0 +1,9 @@
package handlers
func stringPtrOrNil(value string) *string {
if value == "" {
return nil
}
return &value
}
+40
View File
@@ -0,0 +1,40 @@
package bot
import (
"FamilyHub/src/bot/handlers"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Router struct {
handler *handlers.Handler
}
func NewRouter(handler *handlers.Handler) *Router {
return &Router{handler: handler}
}
func (r *Router) Handle(update tgbotapi.Update) {
if update.Message == nil {
return
}
switch {
case update.Message.Photo != nil:
r.handler.HandlePhoto(update.Message)
case update.Message.Text == "/start":
r.handler.HandleStart(update.Message)
case update.Message.Text == "/register":
r.handler.HandleRegister(update.Message)
case update.Message.Text == "/termsOfService":
r.handler.HandleTermsOfService(update.Message)
case update.Message.Text == "/getAgreement":
r.handler.HandleGetAgreement(update.Message)
case update.Message.Text == "/help":
r.handler.HandleHelp(update.Message)
case update.Message.Text == "/createFamily":
r.handler.HandleCreateFamily(update.Message)
default:
r.handler.HandleUnknown(update.Message)
}
}
@@ -17,6 +17,8 @@ type Config struct {
OCRTokenPath string OCRTokenPath string
TelegramApi string
APIPort string APIPort string
APIHost string APIHost string
APISecret string APISecret string
@@ -53,6 +55,9 @@ func Load() (Config, error) {
} }
} }
if runMode == API || runMode == Standalone { if runMode == API || runMode == Standalone {
if ocrTokenPath == "" {
warnings = append(warnings, "Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")
}
if apiSecret == "" { if apiSecret == "" {
warnings = append(warnings, "Missing required environment variable: API_SECRET") warnings = append(warnings, "Missing required environment variable: API_SECRET")
} }
@@ -66,7 +71,7 @@ func Load() (Config, error) {
apiPort = "8000" apiPort = "8000"
} }
if openAPIEndpoint == "" { if openAPIEndpoint == "" {
openAPIEndpoint = "/docs" openAPIEndpoint = "/openapi"
} }
} }
@@ -84,5 +89,6 @@ func Load() (Config, error) {
APISecret: apiSecret, APISecret: apiSecret,
OpenAPIEnabled: openAPIEnabled, OpenAPIEnabled: openAPIEnabled,
OpenAPIEndpoint: openAPIEndpoint, OpenAPIEndpoint: openAPIEndpoint,
TelegramApi: "https://api.telegram.org",
}, nil }, nil
} }
@@ -7,6 +7,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/database/postgres"
@@ -24,6 +25,36 @@ type Database struct {
MaxIdleConns int MaxIdleConns int
} }
func resolveMigrationsPath(path string) (string, error) {
if path == "" {
path = "file://migrations"
}
const filePrefix = "file://"
if !strings.HasPrefix(path, filePrefix) {
return path, nil
}
relativePath := strings.TrimPrefix(path, filePrefix)
candidates := []string{
relativePath,
filepath.Join("backend", relativePath),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
absPath, err := filepath.Abs(candidate)
if err != nil {
return "", err
}
return filePrefix + absPath, nil
}
}
return "", fmt.Errorf("migrations path not found: %s", path)
}
func (d *Database) Connect() (*sql.DB, error) { func (d *Database) Connect() (*sql.DB, error) {
u, _ := url.Parse(d.ConnectionString) u, _ := url.Parse(d.ConnectionString)
if u == nil { if u == nil {
@@ -85,31 +116,36 @@ func (d *Database) RunMigrations(db *sql.DB) error {
return errors.New("nil db") return errors.New("nil db")
} }
if d.MigrationsPath == "" { migrationsPath, err := resolveMigrationsPath(d.MigrationsPath)
d.MigrationsPath = "file://migrations" if err != nil {
return err
} }
var m *migrate.Migrate var m *migrate.Migrate
switch u.Scheme { switch u.Scheme {
case "sqlite", "sqlite3": case "sqlite", "sqlite3":
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) driver, driverErr := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil { if driverErr != nil {
return err return driverErr
} }
m, err = migrate.NewWithDatabaseInstance(d.MigrationsPath, "sqlite", driver) m, err = migrate.NewWithDatabaseInstance(migrationsPath, "sqlite", driver)
case "postgres", "postgresql": case "postgres", "postgresql":
driver, err := postgres.WithInstance(db, &postgres.Config{}) driver, driverErr := postgres.WithInstance(db, &postgres.Config{})
if err != nil { if driverErr != nil {
return err return driverErr
} }
m, err = migrate.NewWithDatabaseInstance(d.MigrationsPath, "postgres", driver) m, err = migrate.NewWithDatabaseInstance(migrationsPath, "postgres", driver)
default: default:
return fmt.Errorf("unsupported database scheme for migrations: %s", u.Scheme) return fmt.Errorf("unsupported database scheme for migrations: %s", u.Scheme)
} }
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migration failed: %w", err) return fmt.Errorf("migration failed: %w", err)
} }
+30
View File
@@ -0,0 +1,30 @@
package domain
import "time"
type ActivityLog struct {
ID int64
FamilyID *int64
UserID int64
Action string
EntityType string
EntityID *int64
Description string
CreatedAt time.Time
}
type ActivityLogCreateRequest struct {
FamilyID *int64
UserID int64
Action string
EntityType string
EntityID *int64
Description string
}
type ActivityLogListFilter struct {
FamilyID *int64
UserID *int64
Limit int
Offset int
}
+8
View File
@@ -0,0 +1,8 @@
package domain
type AuthRequest struct {
TelegramId *string `json:"telegram_id"`
OTP *int64 `json:"otp"`
InitData *string `json:"init_data"`
}
+65
View File
@@ -0,0 +1,65 @@
package domain
import "time"
type Family struct {
ID int64
Name string
OwnerID int64
TelegramChatID *int64
TelegramChatName *string
CreatedAt time.Time
UpdatedAt time.Time
}
type FamilyRole string
const (
FamilyRoleOwner FamilyRole = "owner"
FamilyRoleAdmin FamilyRole = "admin"
FamilyRoleMember FamilyRole = "member"
FamilyRoleChild FamilyRole = "child"
)
type FamilyMember struct {
ID int64
FamilyID int64
UserID int64
Role FamilyRole
JoinedAt time.Time
}
type CreateFamilyRequest struct {
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
TelegramChatID *int64 `json:"telegram_chat_id"`
TelegramChatName *string `json:"telegram_chat_name"`
}
type UpdateFamilyRequest struct {
Name *string `json:"name"`
TelegramChatID *int64 `json:"telegram_chat_id"`
TelegramChatName *string `json:"telegram_chat_name"`
}
type FamilyResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
TelegramChatID *int64 `json:"telegram_chat_id"`
TelegramChatName *string `json:"telegram_chat_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (response *FamilyResponse) ModelToResponse(f *Family) FamilyResponse {
return FamilyResponse{
ID: f.ID,
Name: f.Name,
OwnerID: f.OwnerID,
TelegramChatID: f.TelegramChatID,
TelegramChatName: f.TelegramChatName,
CreatedAt: f.CreatedAt.Format(time.RFC3339),
UpdatedAt: f.UpdatedAt.Format(time.RFC3339),
}
}
+9
View File
@@ -0,0 +1,9 @@
package domain
import "time"
type OTP struct {
UserID int64
Code string
ExpiredAt time.Time
}
@@ -3,6 +3,8 @@ package domain
import "time" import "time"
type Position struct { type Position struct {
ID int64 `json:"id"`
ReceiptID int64 `json:"receipt_id"`
SectionNumber string `json:"section_number"` SectionNumber string `json:"section_number"`
GTINCode string `json:"gtin_code"` GTINCode string `json:"gtin_code"`
Tag string `json:"tag"` Tag string `json:"tag"`
@@ -25,6 +27,7 @@ type Position struct {
type Receipt struct { type Receipt struct {
ID int `json:"id"` ID int `json:"id"`
TransactionID *int64 `json:"transaction_id"`
Status int `json:"STATUS"` Status int `json:"STATUS"`
AnotherAmount float64 `json:"another_amount"` AnotherAmount float64 `json:"another_amount"`
CashAmount float64 `json:"cash_amount"` CashAmount float64 `json:"cash_amount"`
@@ -62,3 +65,20 @@ type Receipt struct {
PositionsRaw string `json:"positions"` PositionsRaw string `json:"positions"`
Positions []Position `json:"-"` Positions []Position `json:"-"`
} }
type AddReceiptRequest struct {
Number string `json:"number" binding:"required,min=24,max=24"`
Date string `json:"date" binding:"required"`
FamilyID *int64 `json:"family_id"`
CreatedBy *int64 `json:"created_by"`
Type *string `json:"type"`
Category *string `json:"category"`
Description *string `json:"description"`
}
type AddReceiptResponse struct {
ID int32 `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
TransactionID *int64 `json:"transaction_id,omitempty"`
}
+61
View File
@@ -0,0 +1,61 @@
package domain
import "time"
type Transaction struct {
ID int64
FamilyID int64
Description *string
Type string
DateTime time.Time
Category string
Amount float64
CreatedAt time.Time
CreatedBy int64
ReceiptID *int64
}
type CreateTransactionRequest struct {
FamilyID int64
Description *string
Type string
DateTime time.Time
Category string
Amount float64
CreatedBy int64
ReceiptID *int64
}
type UpdateTransactionRequest struct {
Description *string
Type *string
DateTime *time.Time
Category *string
Amount *float64
ReceiptID *int64
DetachReceipt bool
}
type TransactionListFilter struct {
FamilyID *int64
CreatedBy *int64
Type *string
Category *string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type TransactionAnalyticsFilter struct {
FamilyID *int64
Type *string
DateFrom time.Time
DateTo time.Time
}
type TransactionAnalytics struct {
Expenses float64
Incomes float64
Total float64
}
@@ -1,39 +1,47 @@
package dto package domain
import ( import (
"FamilyHub/src/domain"
"time" "time"
) )
type UserModel struct {
ID int64
TelegramID int64
Username *string
FirstName *string
LastName *string
LanguageCode *string
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateUserRequest struct { type CreateUserRequest struct {
TelegramID int64 `json:"telegram_id" validate:"required"` TelegramID int64 `json:"telegram_id" validate:"required"`
Username *string `json:"username"` Username *string `json:"username"`
FirstName string `json:"first_name" validate:"required"` FirstName *string `json:"first_name" validate:"required"`
LastName *string `json:"last_name"` LastName *string `json:"last_name"`
LanguageCode *string `json:"language_code"` LanguageCode *string `json:"language_code"`
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
FirstName *string `json:"first_name"` FirstName *string `json:"first_name"`
LastName *string `json:"last_name"` LastName *string `json:"last_name"`
LanguageCode *string `json:"language_code"` LanguageCode *string `json:"language_code"`
} }
type UserResponse struct { type UserResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TelegramID int64 `json:"telegram_id"` TelegramID int64 `json:"telegram_id"`
Username *string `json:"username"` Username *string `json:"username"`
FirstName string `json:"first_name"` FirstName *string `json:"first_name"`
LastName *string `json:"last_name"` LastName *string `json:"last_name"`
LanguageCode *string `json:"language_code"` LanguageCode *string `json:"language_code"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
type UserErrorResponse struct { func (response *UserResponse) ModelToResponse(u *UserModel) UserResponse {
Error string `json:"error"`
}
func (response *UserResponse) ModelToResponse(u *domain.User) UserResponse {
return UserResponse{ return UserResponse{
ID: u.ID, ID: u.ID,
TelegramID: u.TelegramID, TelegramID: u.TelegramID,
@@ -0,0 +1,198 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)
var errUserNotFound = errors.New("user not found")
func NewApiClient(config config.Config) (*HTTPClient, error) {
return &HTTPClient{
config: config,
client: &http.Client{
Timeout: 60 * time.Second,
},
}, nil
}
func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error {
requestBody := map[string]any{
"receipt_number": payload.Number,
"receipt_date": payload.Date,
}
if payload.FamilyID != nil {
requestBody["family_id"] = *payload.FamilyID
}
if payload.CreatedBy != nil {
requestBody["created_by"] = *payload.CreatedBy
}
if payload.Type != nil {
requestBody["type"] = *payload.Type
}
if payload.Category != nil {
requestBody["category"] = *payload.Category
}
if payload.Description != nil {
requestBody["description"] = *payload.Description
}
body, err := json.Marshal(requestBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.config.APIHost+c.config.APIPort+"/api/v1/transactions",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
func (c *HTTPClient) EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error {
registered, err := c.IsUserRegistered(ctx, payload.TelegramID)
if err != nil {
return err
}
if registered {
return nil
}
return c.RegisterUser(ctx, payload)
}
func (c *HTTPClient) IsUserRegistered(ctx context.Context, telegramID int64) (bool, error) {
_, err := c.GetUserByTelegramID(ctx, telegramID)
if err == nil {
return true, nil
}
if errors.Is(err, errUserNotFound) {
return false, nil
}
return false, err
}
func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.config.APIHost+c.config.APIPort+"/api/v1/users",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
c.config.APIHost+c.config.APIPort+"/api/v1/users/by-telegram/"+strconv.FormatInt(telegramID, 10),
nil,
)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, errUserNotFound
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
}
var user domain.UserResponse
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.config.APIHost+c.config.APIPort+"/api/v1/families",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,172 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func strPtr(v string) *string {
return &v
}
func testConfig(baseURL string) config.Config {
return config.Config{
APIHost: baseURL,
}
}
func TestHTTPClient_SendReceipt_UsesTransactionsEndpoint(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/transactions" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if payload["receipt_number"] != "123" || payload["receipt_date"] != "21.01.2026" {
t.Fatalf("unexpected payload: %+v", payload)
}
w.WriteHeader(http.StatusCreated)
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.SendReceipt(context.Background(), domain.AddReceiptRequest{
Number: "123",
Date: "21.01.2026",
})
if err != nil {
t.Fatalf("SendReceipt returned error: %v", err)
}
}
func TestHTTPClient_EnsureUser_AlreadyExists(t *testing.T) {
var postCalls int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(domain.UserResponse{
TelegramID: 100500,
FirstName: strPtr("John"),
})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
atomic.AddInt32(&postCalls, 1)
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
TelegramID: 100500,
FirstName: strPtr("John"),
})
if err != nil {
t.Fatalf("EnsureUser returned error: %v", err)
}
if got := atomic.LoadInt32(&postCalls); got != 0 {
t.Fatalf("expected no POST calls, got %d", got)
}
}
func TestHTTPClient_EnsureUser_CreateOnNotFound(t *testing.T) {
var getCalls int32
var postCalls int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
atomic.AddInt32(&getCalls, 1)
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
atomic.AddInt32(&postCalls, 1)
var req domain.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if req.TelegramID != 100500 || req.FirstName == nil || *req.FirstName != "John" {
t.Fatalf("unexpected payload: %+v", req)
}
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
TelegramID: 100500,
FirstName: strPtr("John"),
})
if err != nil {
t.Fatalf("EnsureUser returned error: %v", err)
}
if got := atomic.LoadInt32(&getCalls); got != 1 {
t.Fatalf("expected 1 GET call, got %d", got)
}
if got := atomic.LoadInt32(&postCalls); got != 1 {
t.Fatalf("expected 1 POST call, got %d", got)
}
}
func TestHTTPClient_EnsureUser_ReturnsErrorWhenLookupFails(t *testing.T) {
var postCalls int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
w.WriteHeader(http.StatusInternalServerError)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
atomic.AddInt32(&postCalls, 1)
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
TelegramID: 100500,
FirstName: strPtr("John"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
if got := atomic.LoadInt32(&postCalls); got != 0 {
t.Fatalf("expected no POST calls, got %d", got)
}
}
@@ -0,0 +1,36 @@
package familyHub
import (
"FamilyHub/src/config"
"context"
"net/http"
"strconv"
"time"
)
func NewBotClient(config config.Config) (*HTTPClient, error) {
return &HTTPClient{
config: config,
client: &http.Client{
Timeout: 60 * time.Second,
},
}, nil
}
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message,
nil,
)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
@@ -0,0 +1,26 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"context"
"net/http"
)
type ApiClient interface {
SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error
EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error
IsUserRegistered(ctx context.Context, telegramID int64) (bool, error)
RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error
GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error)
CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error
}
type BotClient interface {
SendMessage(ctx context.Context, chatId int64, message string) error
}
type HTTPClient struct {
config config.Config
client *http.Client
}
@@ -1,4 +1,4 @@
package receiptApi package familyHub
type ReceiptPayload struct { type ReceiptPayload struct {
Number string `json:"number"` Number string `json:"number"`
@@ -22,6 +22,10 @@ func NewGoogleOCR(ctx context.Context) (*GoogleOCR, error) {
} }
func (g *GoogleOCR) Close() error { func (g *GoogleOCR) Close() error {
if g == nil || g.client == nil {
return nil
}
return g.client.Close() return g.client.Close()
} }
@@ -1,8 +1,7 @@
package receiptService package receiptProvider
import ( import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/repositories"
"FamilyHub/src/utils" "FamilyHub/src/utils"
"bytes" "bytes"
"context" "context"
@@ -10,19 +9,42 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strings"
"time" "time"
) )
type ReceiptService struct { type ReceiptProvider interface {
client *http.Client GetReceipt(ctx context.Context, date, number string) (*domain.Receipt, error)
repo repositories.ReceiptsRepository
} }
func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService { var (
return &ReceiptService{ ErrReceiptNotFound = errors.New("receipt not found")
ErrExternalService = errors.New("external receipt service failure")
)
type ExternalServiceError struct {
StatusCode int
Body string
}
func (e *ExternalServiceError) Error() string {
return fmt.Sprintf("%s: status %d", ErrExternalService, e.StatusCode)
}
func (e *ExternalServiceError) Unwrap() error {
return ErrExternalService
}
type receiptProvider struct {
client *http.Client
}
func NewReceiptProvider() *receiptProvider {
return &receiptProvider{
client: &http.Client{ client: &http.Client{
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
@@ -31,20 +53,18 @@ func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService {
}, },
}, },
}, },
repo: repo,
} }
} }
func (s *ReceiptService) GetReceipt( func (s *receiptProvider) GetReceipt(
ctx context.Context, ctx context.Context,
date string, date, number string,
number string,
) (*domain.Receipt, error) { ) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php" url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number) body, contentType := buildMultipartBody(date, number)
req, err := http.NewRequestWithContext( httpReq, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
url, url,
@@ -55,9 +75,9 @@ func (s *ReceiptService) GetReceipt(
return nil, err return nil, err
} }
req.Header.Set("Content-Type", contentType) httpReq.Header.Set("Content-Type", contentType)
resp, err := s.client.Do(req) resp, err := s.client.Do(httpReq)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
return nil, err return nil, err
@@ -65,8 +85,16 @@ func (s *ReceiptService) GetReceipt(
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
log.Printf("external service returned %d\n", resp.StatusCode) responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("external service returned %d", resp.StatusCode) if readErr != nil {
log.Printf("failed to read external service error body: %v", readErr)
}
bodyText := strings.TrimSpace(string(responseBody))
log.Printf("external service returned %d body=%q", resp.StatusCode, bodyText)
return nil, &ExternalServiceError{
StatusCode: resp.StatusCode,
Body: bodyText,
}
} }
var raw struct { var raw struct {
@@ -84,7 +112,7 @@ func (s *ReceiptService) GetReceipt(
} }
if receipt.IssuedAtRaw == "" { if receipt.IssuedAtRaw == "" {
return nil, errors.New("receipt not found") return nil, ErrReceiptNotFound
} }
positions, err := parsePositions(receipt.PositionsRaw) positions, err := parsePositions(receipt.PositionsRaw)
@@ -119,9 +147,6 @@ func (s *ReceiptService) GetReceipt(
p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw) p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw)
} }
if _, err := s.repo.Create(ctx, &receipt); err != nil {
return nil, err
}
return &receipt, nil return &receipt, nil
} }
+4 -1
View File
@@ -32,7 +32,10 @@ func main() {
} }
if cfg.RunMode == config.Bot || cfg.RunMode == config.Standalone { if cfg.RunMode == config.Bot || cfg.RunMode == config.Standalone {
tgBot, _ := bot.NewBot(cfg) tgBot, err := bot.NewBot(cfg)
if err != nil {
log.Fatal(err)
}
log.Println("Bot started...") log.Println("Bot started...")
runnable = append(runnable, func(ctx context.Context) error { runnable = append(runnable, func(ctx context.Context) error {
return tgBot.Start(ctx) return tgBot.Start(ctx)
+98
View File
@@ -0,0 +1,98 @@
package repositories
import (
"FamilyHub/src/domain"
"context"
"database/sql"
"fmt"
"strings"
)
type ActivityRepository interface {
Create(ctx context.Context, activity *domain.ActivityLog) error
List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, error)
}
type ActivitySQLRepository struct {
db *sql.DB
}
func NewActivitySQLRepository(db *sql.DB) *ActivitySQLRepository {
return &ActivitySQLRepository{db: db}
}
func (r *ActivitySQLRepository) Create(ctx context.Context, activity *domain.ActivityLog) error {
query := `
INSERT INTO activity_logs (family_id, user_id, action, entity_type, entity_id, description)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`
return r.db.QueryRowContext(
ctx,
query,
activity.FamilyID,
activity.UserID,
activity.Action,
activity.EntityType,
activity.EntityID,
activity.Description,
).Scan(&activity.ID, &activity.CreatedAt)
}
func (r *ActivitySQLRepository) List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, error) {
var (
whereClauses []string
args []any
)
appendFilter := func(condition string, value any) {
args = append(args, value)
whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args)))
}
query := `
SELECT id, family_id, user_id, action, entity_type, entity_id, description, created_at
FROM activity_logs
`
if filter.FamilyID != nil {
appendFilter("family_id = $%d", *filter.FamilyID)
}
if filter.UserID != nil {
appendFilter("user_id = $%d", *filter.UserID)
}
if len(whereClauses) > 0 {
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
args = append(args, filter.Limit, filter.Offset)
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []*domain.ActivityLog
for rows.Next() {
var activity domain.ActivityLog
if err := rows.Scan(
&activity.ID,
&activity.FamilyID,
&activity.UserID,
&activity.Action,
&activity.EntityType,
&activity.EntityID,
&activity.Description,
&activity.CreatedAt,
); err != nil {
return nil, err
}
activities = append(activities, &activity)
}
return activities, rows.Err()
}
@@ -93,7 +93,6 @@ func (r *FamilySQLRepository) Update(ctx context.Context, family *domain.Family)
family.Name, family.Name,
family.TelegramChatID, family.TelegramChatID,
family.TelegramChatName, family.TelegramChatName,
family.UpdatedAt,
family.ID, family.ID,
).Scan(&family.UpdatedAt) ).Scan(&family.UpdatedAt)
} }
+63
View File
@@ -0,0 +1,63 @@
package repositories
import (
"FamilyHub/src/domain"
"context"
"database/sql"
"errors"
)
type OTPRepository interface {
Create(ctx context.Context, otp *domain.OTP) error
Get(ctx context.Context, userID int64, code string) (*domain.OTP, error)
}
type OTPSQLRepository struct {
db *sql.DB
}
func NewOTPSQLRepository(db *sql.DB) *OTPSQLRepository {
return &OTPSQLRepository{db: db}
}
func (r *OTPSQLRepository) Create(ctx context.Context, otp *domain.OTP) error {
query := `
INSERT INTO otp (user_id, otp, expired_at)
VALUES ($1, $2, $3)
`
_, err := r.db.ExecContext(ctx, query, otp.UserID, otp.Code, otp.ExpiredAt)
return err
}
func (r *OTPSQLRepository) Get(ctx context.Context, userID int64, code string) (*domain.OTP, error) {
query := `
DELETE FROM otp
WHERE ctid IN (
SELECT ctid
FROM otp
WHERE user_id = $1
AND otp = $2
AND expired_at > NOW()
ORDER BY expired_at
LIMIT 1
)
RETURNING user_id, otp, expired_at
`
var otp domain.OTP
err := r.db.QueryRowContext(ctx, query, userID, code).Scan(
&otp.UserID,
&otp.Code,
&otp.ExpiredAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &otp, nil
}
@@ -36,7 +36,7 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
} }
res, err := tx.ExecContext(ctx, ` res, err := tx.ExecContext(ctx, `
INSERT INTO receipts ( INSERT INTO receipts (
receipt_number, ui, status, issued_at, transaction_id, receipt_number, ui, status, issued_at,
total_amount, payment_amount, cash_amount, total_amount, payment_amount, cash_amount,
another_amount, clearing_amount, margin, another_amount, clearing_amount, margin,
currency, payment_type, currency, payment_type,
@@ -46,8 +46,9 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
kod_soato, oblast_soato, rayon_soato, selsovet_soato, kod_soato, oblast_soato, rayon_soato, selsovet_soato,
doc_num, skno_number, unp, doc_num, skno_number, unp,
success success
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
receipt.TransactionID,
receipt.ReceiptNumber, receipt.ReceiptNumber,
receipt.UI, receipt.UI,
receipt.Status, receipt.Status,
@@ -144,6 +145,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT SELECT
id, id,
transaction_id,
receipt_number, ui, status, issued_at, receipt_number, ui, status, issued_at,
total_amount, payment_amount, cash_amount, total_amount, payment_amount, cash_amount,
another_amount, clearing_amount, margin, another_amount, clearing_amount, margin,
@@ -158,6 +160,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
WHERE id = ? WHERE id = ?
`, id).Scan( `, id).Scan(
&receipt.ID, &receipt.ID,
&receipt.TransactionID,
&receipt.ReceiptNumber, &receipt.ReceiptNumber,
&receipt.UI, &receipt.UI,
&receipt.Status, &receipt.Status,
@@ -205,6 +208,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT SELECT
id, receipt_id,
section_number, gtin_code, product_name, section_number, gtin_code, product_name,
product_count, amount, product_count, amount,
discount, surcharge, discount, surcharge,
@@ -219,6 +223,8 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
for rows.Next() { for rows.Next() {
var p domain.Position var p domain.Position
if err := rows.Scan( if err := rows.Scan(
&p.ID,
&p.ReceiptID,
&p.SectionNumber, &p.SectionNumber,
&p.GTINCode, &p.GTINCode,
&p.ProductName, &p.ProductName,
@@ -241,7 +247,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) { func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, receipt_number, issued_at, total_amount, currency SELECT id, transaction_id, receipt_number, issued_at, total_amount, currency
FROM receipts FROM receipts
ORDER BY issued_at DESC ORDER BY issued_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@@ -257,6 +263,7 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
var rct domain.Receipt var rct domain.Receipt
if err := rows.Scan( if err := rows.Scan(
&rct.ID, &rct.ID,
&rct.TransactionID,
&rct.ReceiptNumber, &rct.ReceiptNumber,
&rct.IssuedAt, &rct.IssuedAt,
&rct.TotalAmount, &rct.TotalAmount,
@@ -280,11 +287,13 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
UPDATE receipts SET UPDATE receipts SET
transaction_id = ?,
issued_at = ?, issued_at = ?,
total_amount = ?, total_amount = ?,
currency = ? currency = ?
WHERE id = ? WHERE id = ?
`, `,
receipt.TransactionID,
receipt.IssuedAt, receipt.IssuedAt,
receipt.TotalAmount, receipt.TotalAmount,
receipt.Currency, receipt.Currency,
+321
View File
@@ -0,0 +1,321 @@
package repositories
import (
"FamilyHub/src/domain"
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
var ErrReceiptNotFound = errors.New("receipt not found")
type TransactionRepository interface {
Create(ctx context.Context, transaction *domain.Transaction) error
GetByID(ctx context.Context, id int64) (*domain.Transaction, error)
List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error)
Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error)
Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error
Delete(ctx context.Context, id int64) error
}
type TransactionsSQLRepository struct {
db *sql.DB
}
func NewTransactionsSQLRepository(db *sql.DB) *TransactionsSQLRepository {
return &TransactionsSQLRepository{db: db}
}
func (r *TransactionsSQLRepository) Create(ctx context.Context, transaction *domain.Transaction) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query := `
INSERT INTO transactions
(family_id, description, type, datetime, category, amount, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at
`
if err := tx.QueryRowContext(
ctx,
query,
transaction.FamilyID,
transaction.Description,
transaction.Type,
transaction.DateTime,
transaction.Category,
transaction.Amount,
transaction.CreatedBy,
).Scan(&transaction.ID, &transaction.CreatedAt); err != nil {
return err
}
if err := r.rebindReceipt(ctx, tx, transaction.ID, transaction.ReceiptID); err != nil {
return err
}
return tx.Commit()
}
func (r *TransactionsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
query := `
SELECT
t.id,
t.family_id,
t.description,
t.type,
t.datetime,
t.category,
t.amount,
t.created_at,
t.created_by,
r.id
FROM transactions t
LEFT JOIN receipts r ON r.transaction_id = t.id
WHERE t.id = $1
`
var transaction domain.Transaction
err := r.db.QueryRowContext(ctx, query, id).Scan(
&transaction.ID,
&transaction.FamilyID,
&transaction.Description,
&transaction.Type,
&transaction.DateTime,
&transaction.Category,
&transaction.Amount,
&transaction.CreatedAt,
&transaction.CreatedBy,
&transaction.ReceiptID,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &transaction, nil
}
func (r *TransactionsSQLRepository) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
var (
whereClauses []string
args []any
)
baseQuery := `
SELECT
t.id,
t.family_id,
t.description,
t.type,
t.datetime,
t.category,
t.amount,
t.created_at,
t.created_by,
r.id
FROM transactions t
LEFT JOIN receipts r ON r.transaction_id = t.id
`
appendFilter := func(condition string, value any) {
args = append(args, value)
whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args)))
}
if filter.FamilyID != nil {
appendFilter("t.family_id = $%d", *filter.FamilyID)
}
if filter.CreatedBy != nil {
appendFilter("t.created_by = $%d", *filter.CreatedBy)
}
if filter.Type != nil {
appendFilter("t.type = $%d", *filter.Type)
}
if filter.Category != nil {
appendFilter("t.category = $%d", *filter.Category)
}
if filter.DateFrom != nil {
appendFilter("t.datetime >= $%d", *filter.DateFrom)
}
if filter.DateTo != nil {
appendFilter("t.datetime <= $%d", *filter.DateTo)
}
var queryBuilder strings.Builder
queryBuilder.WriteString(baseQuery)
if len(whereClauses) > 0 {
queryBuilder.WriteString(" WHERE ")
queryBuilder.WriteString(strings.Join(whereClauses, " AND "))
}
args = append(args, filter.Limit, filter.Offset)
queryBuilder.WriteString(fmt.Sprintf(" ORDER BY t.datetime DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args)))
rows, err := r.db.QueryContext(ctx, queryBuilder.String(), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var transactions []*domain.Transaction
for rows.Next() {
var transaction domain.Transaction
if err := rows.Scan(
&transaction.ID,
&transaction.FamilyID,
&transaction.Description,
&transaction.Type,
&transaction.DateTime,
&transaction.Category,
&transaction.Amount,
&transaction.CreatedAt,
&transaction.CreatedBy,
&transaction.ReceiptID,
); err != nil {
return nil, err
}
transactions = append(transactions, &transaction)
}
return transactions, rows.Err()
}
func (r *TransactionsSQLRepository) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) {
var (
whereClauses []string
args []any
)
appendFilter := func(condition string, value any) {
args = append(args, value)
whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args)))
}
appendFilter("datetime >= $%d", filter.DateFrom)
appendFilter("datetime <= $%d", filter.DateTo)
if filter.FamilyID != nil {
appendFilter("family_id = $%d", *filter.FamilyID)
}
if filter.Type != nil {
appendFilter("type = $%d", *filter.Type)
}
query := `
SELECT
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0)
FROM transactions
`
if len(whereClauses) > 0 {
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
var analytics domain.TransactionAnalytics
if err := r.db.QueryRowContext(ctx, query, args...).Scan(&analytics.Expenses, &analytics.Incomes); err != nil {
return domain.TransactionAnalytics{}, err
}
analytics.Total = analytics.Incomes - analytics.Expenses
return analytics, nil
}
func (r *TransactionsSQLRepository) Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query := `
UPDATE transactions SET
description = $1,
type = $2,
datetime = $3,
category = $4,
amount = $5
WHERE id = $6
`
result, err := tx.ExecContext(
ctx,
query,
transaction.Description,
transaction.Type,
transaction.DateTime,
transaction.Category,
transaction.Amount,
transaction.ID,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
if syncReceipt {
if err := r.rebindReceipt(ctx, tx, transaction.ID, transaction.ReceiptID); err != nil {
return err
}
}
return tx.Commit()
}
func (r *TransactionsSQLRepository) Delete(ctx context.Context, id int64) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM transactions WHERE id = $1`, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *TransactionsSQLRepository) rebindReceipt(ctx context.Context, tx *sql.Tx, transactionID int64, receiptID *int64) error {
if _, err := tx.ExecContext(ctx, `UPDATE receipts SET transaction_id = NULL WHERE transaction_id = $1`, transactionID); err != nil {
return err
}
if receiptID == nil {
return nil
}
result, err := tx.ExecContext(ctx, `UPDATE receipts SET transaction_id = $1 WHERE id = $2`, transactionID, *receiptID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrReceiptNotFound
}
return nil
}
@@ -8,10 +8,10 @@ import (
) )
type UsersRepository interface { type UsersRepository interface {
Create(ctx context.Context, user *domain.User) error Create(ctx context.Context, user *domain.UserModel) error
GetByID(ctx context.Context, id int64) (*domain.User, error) GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
Update(ctx context.Context, user *domain.User) error Update(ctx context.Context, user *domain.UserModel) error
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
} }
@@ -23,7 +23,7 @@ func NewUsersSQLRepository(db *sql.DB) *UsersSQLRepository {
return &UsersSQLRepository{db: db} return &UsersSQLRepository{db: db}
} }
func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.User) error { func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.UserModel) error {
query := ` query := `
INSERT INTO users INSERT INTO users
(telegram_id, username, first_name, last_name, language_code) (telegram_id, username, first_name, last_name, language_code)
@@ -41,7 +41,7 @@ func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.User) erro
user.LanguageCode, user.LanguageCode,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
} }
func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) { func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
query := ` query := `
SELECT SELECT
id, id,
@@ -56,7 +56,7 @@ func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Use
WHERE id = $1 WHERE id = $1
` `
var user domain.User var user domain.UserModel
err := r.db.QueryRowContext(ctx, query, id).Scan( err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.ID,
@@ -78,7 +78,7 @@ func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Use
return &user, nil return &user, nil
} }
func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) { func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
query := ` query := `
SELECT SELECT
id, id,
@@ -93,7 +93,7 @@ func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int
WHERE telegram_id = $1 WHERE telegram_id = $1
` `
var user domain.User var user domain.UserModel
err := r.db.QueryRowContext(ctx, query, telegramID).Scan( err := r.db.QueryRowContext(ctx, query, telegramID).Scan(
&user.ID, &user.ID,
@@ -115,7 +115,7 @@ func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int
return &user, nil return &user, nil
} }
func (r *UsersSQLRepository) Update(ctx context.Context, user *domain.User) error { func (r *UsersSQLRepository) Update(ctx context.Context, user *domain.UserModel) error {
query := ` query := `
UPDATE users SET UPDATE users SET
username = $1, username = $1,
+25
View File
@@ -0,0 +1,25 @@
package utils
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTManager struct {
secret string
}
func NewJWTManager(secret string) *JWTManager {
return &JWTManager{secret: secret}
}
func (j *JWTManager) Generate(userID int64) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(j.secret))
}
+369
View File
@@ -0,0 +1,369 @@
# 📘 Финансовый модуль
## 1. Общее описание
Финансовый модуль предназначен для учёта:
- доходов
- расходов
- категорий
Поддерживает три способа ввода расходов:
1. Ручной ввод
2. Ввод номера и даты чека
3. Загрузка фото чека
---
## 2. Глоссарий
**Доход (Income)**
Денежное поступление (зарплата, перевод, подарок и т.д.)
**Расход (Expense)**
Факт траты денег
**Категория (Category)**
Классификация доходов и расходов (например: еда, транспорт, зарплата)
**Чек (Receipt)**
Документ, содержащий информацию о покупке (дата, позиции, сумма)
**Позиция (Position)**
Отдельная строка в чеке (товар или услуга)
---
## 3. Доменная модель
### 3.1 Positions
```
positions (
id,
receipt_number,
operation_date,
gtin_code,
product_name,
product_count,
amount,
discount,
name_spd,
category_id,
family_id,
family_member_id,
created_at,
updated_at
)
```
**Описание полей:**
- `receipt_number` — номер чека (nullable для ручного ввода)
- `operation_date` — дата операции
- `gtin_code` — код товара
- `product_name` — название товара
- `product_count` — количество
- `amount` — сумма позиции
- `discount` — скидка
- `name_spd` — продавец
- `category_id` — категория
- `family_id` — семья
- `family_member_id` — участник
---
### 3.2 Receipts
```
receipts (
id,
receipt_number,
ui,
status,
issued_at,
total_amount,
payment_amount,
cash_amount,
another_amount,
clearing_amount,
margin,
currency,
payment_type,
cashbox_number,
cashier,
name_spd,
name_to,
name_np,
type_np,
street_to,
house_to,
kod_soato,
oblast_soato,
rayon_soato,
selsovet_soato,
doc_num,
skno_number,
unp,
success,
family_id,
family_member_id,
created_at
)
```
**Описание:**
- хранит агрегированную информацию о чеке
- используется при сканировании QR
- связывается с positions через `receipt_number`
---
### 3.3 Categories
```
categories (
id,
name,
type,
family_id,
family_member_id,
created_at
)
```
**Описание полей:**
- `type` — income | expense
- категория принадлежит семье
---
## 4. Бизнес-логика
### 4.1 Добавление расхода вручную
- пользователь вводит:
- сумму
- описание
- категорию
- создаётся запись в `positions`
- `receipt_number = NULL`
---
### 4.2 Добавление дохода
- аналогично расходу
- используется категория типа `income`
---
### 4.3 Добавление расхода по номеру и дате чека
#### Поток:
1. Пользователь вводит номер и дату чека
2. Backend получает данные чека через внешний сервис
3. Создаётся запись в `receipts`
4. Создаётся связанная транзакция в `transactions`
5. Для каждой позиции создаётся запись в `positions`
---
### 4.4 Добавление расхода по фото чека
#### Поток:
1. Пользователь загружает фото чека
2. Backend извлекает текст через OCR
3. Backend извлекает из текста номер и дату чека
4. Backend получает данные чека через внешний сервис
5. Создаётся запись в `receipts`
6. Создаётся связанная транзакция в `transactions`
7. Для каждой позиции создаётся запись в `positions`
---
## 5. Потоки
### Ручной ввод
```text
User → API POST /transactions → transactions
```
### Доход
```text
User → API POST /transactions → transactions
```
### Чек по номеру и дате
```text
User → API POST /transactions → receipt provider → receipts + positions + transactions
```
### Чек по фото
```text
User → API POST /transactions multipart/form-data → OCR → receipt provider → receipts + positions + transactions
```
---
## 6. API (черновик)
### Создание транзакции вручную
```http
POST /api/v1/transactions
Content-Type: application/json
```
```json
{
"family_id": 1,
"created_by": 2,
"type": "expense",
"category": "groceries",
"amount": 1000,
"description": "Продукты",
"datetime": "2026-01-21T10:11:12Z"
}
```
---
### Создание транзакции по номеру и дате чека
```http
POST /api/v1/transactions
Content-Type: application/json
```
```json
{
"family_id": 1,
"created_by": 2,
"receipt_number": "0123456789ABCDEFGHIJKLMN",
"receipt_date": "21.01.2026"
}
```
---
### Создание транзакции по фото чека
```http
POST /api/v1/transactions
Content-Type: multipart/form-data
```
Поля формы:
- `photo` — файл изображения чека, обязательно
- `family_id` — ID семьи, обязательно
- `created_by` — ID пользователя, обязательно
- `type` — тип транзакции, опционально, по умолчанию `expense`
- `category` — категория, опционально, по умолчанию `receipt`
- `description` — описание транзакции, опционально
---
### Получение транзакций
```http
GET /api/v1/transactions
```
Фильтры:
- дата
- категория
- тип
- family_id
---
### Правила для `POST /api/v1/transactions`
Используется ровно один сценарий создания:
1. Ручная транзакция:
обязательны `family_id`, `created_by`, `type`, `category`, `amount`, `datetime`
2. Транзакция по чеку:
обязательны `family_id`, `created_by`, `receipt_number`, `receipt_date`
3. Транзакция по фото:
обязательны `photo`, `family_id`, `created_by`
Нельзя смешивать ручные поля транзакции (`amount`, `datetime`, `receipt_id`) с полями чека (`receipt_number`, `receipt_date`) в одном JSON-запросе.
---
## 7. Задачи для разработки
### Этап 1 — База
- [ ] Переписать SQL-миграции (positions, receipts, categories)
---
### Этап 2 — Категории
- [ ] CRUD категорий
- [ ] Валидация типа (income/expense)
---
### Этап 3 — Транзакции
- [x] Endpoint создания транзакции
- [x] Endpoint получения списка
- [ ] Фильтрация
---
### Этап 4 — Доходы/расходы
- [ ] Определение типа через категорию
- [ ] Валидация соответствия
---
### Этап 5 — Чеки
- [x] Endpoint загрузки фото чека через `POST /transactions`
- [ ] Интеграция с сервисом чеков
- [x] Создание receipts
- [x] Создание транзакции по номеру и дате чека
- [x] Создание транзакции по фото чека
- [ ] Создание positions
---
### Этап 6 — Telegram интеграция
- [ ] Команды добавления дохода/расхода
- [ ] Обработка QR
---
### Этап 7 — Дополнительно
- [ ] Автокатегоризация
- [ ] Статистика
- [ ] Лимиты
---
## 8. Архитектурные решения
- Transaction — основная сущность финансов
- Receipt — дополняет транзакцию
- Position - позиции из чека
- Категории определяют тип операции
- Поддержка multi-tenant через family_id
---
## 9. Открытые вопросы
- [ ] Нужна ли мультивалютность?
- [ ] Можно ли редактировать чек?
- [ ] Как обрабатывать ошибки OCR?
- [ ] Нужен ли отдельный endpoint для повторной привязки чека к существующей транзакции?
- [ ] Нужны ли роли внутри семьи?
+29
View File
@@ -0,0 +1,29 @@
# Бизнес процессы
## Оглавление
## Активация бота
- Пользователь активирует бота и отправляет команду */start*
- Бот стартует, присылает юзеру приветственное сообщение с информацией о том что он за бот и что он
умеет
- Пользователю становятся доступны кнопки/команды */register*, */termsOfService*, *help*.
- Прочие команды игнорируются
## Мультитенантность
### Регистрация пользователя
- По команде */register* бот идёт в апи, проверяет зарегистрирован ли пользователь и если нет то
присылает пользователю лицензионное соглашение.
- Далее появляется кнопка */getAgreement* после нажатия которой пользователь должен самостоятельно
ввести некоторый текст, который будет являться подтверждением принятия условий. в прочих ситуациях
кнопка *getAgreement* не доступна
- После успешного принятия условий бот регистрирует пользователя в системе.
- После успешной регистрации пользователю доступны команды *createFamily*, *help*, *info*
### Создание или присоединение к семейному аккаунту
- По команде *createFamily* бот проверяет есть ли у этого пользователя уже созданные семейные чаты
- если нет, то предлагает создать новый чат, запрашивает имя чата, картинку на иконку чата и создаёт
супергруппу с темами
- или предлагает присоединиться к семье, запрашивает код, который может выдать владелец семьи
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

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