17 Commits

Author SHA1 Message Date
admin 39425af43e Added possibility deploy with k3s 2026-05-26 23:02:11 +03:00
admin e6096c98fa Merge pull request 'Made autodeploy pipeline' (#9) from 8-Add-Pipelines-for-autodeploy into main
Build and Deploy / build-and-deploy (push) Has been cancelled
Reviewed-on: #9
2026-05-19 22:02:05 +03:00
admin b6447cce63 Made autodeploy pipeline 2026-05-19 22:01:20 +03:00
admin b17b43b17a Merge pull request '2 Сделать отображение транзакций на фронте' (#7) from 2-made-transactions-UI into main
Reviewed-on: #7
2026-05-17 20:23:02 +03:00
admin baef5a0af2 2 Сделать отображение транзакций на фронте 2026-05-17 18:36:14 +03:00
admin a4f9bb63aa Merge pull request 'Added structured logging across services and repositories. Updated SQL queries to use parameterized placeholders for better readability and security. Enhanced error handling for external service communication.' (#3) from 1-Fix-transactions into main
Reviewed-on: #3
2026-05-15 23:40:14 +03:00
admin 8462b16305 Added structured logging across services and repositories. Updated SQL queries to use parameterized placeholders for better readability and security. Enhanced error handling for external service communication. 2026-05-15 22:07:03 +03:00
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
165 changed files with 14355 additions and 2787 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push postgres image
uses: docker/build-push-action@v5
if: |
contains(github.event.commits[0].modified, 'infra/docker/postgres-pg-cron') ||
contains(github.event.commits[0].added, 'infra/docker/postgres-pg-cron')
with:
context: .
file: infra/docker/postgres-pg-cron/Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub-postgres:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push app image
uses: docker/build-push-action@v5
with:
context: .
file: infra/docker/application/Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:latest
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
- name: Deploy to k3s
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/application -n family-hub
kubectl rollout restart deployment/postgres -n family-hub
kubectl rollout status deployment/application -n family-hub --timeout=120s
kubectl rollout status deployment/postgres -n family-hub --timeout=120s
+4
View File
@@ -5,3 +5,7 @@ secret_key.json
data data
archive archive
volumes volumes
*.dtmp
*.gocache
infra/k8s/secrets.yaml
infra/k8s/google-creds.yaml
+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=
@@ -0,0 +1 @@
DROP EXTENSION pg_cron;
@@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS pg_cron;
@@ -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)
+412
View File
@@ -0,0 +1,412 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"errors"
"io"
"log"
"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
}
log.Printf("%+v\n", input)
transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil {
handleTransactionError(c, err)
return
}
log.Printf("%+v\n", transaction)
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
+151
View File
@@ -0,0 +1,151 @@
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)
// подключаем статику Vue — должно быть последним
registerStaticFiles(router)
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
+132
View File
@@ -0,0 +1,132 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/integrations/receiptProvider"
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context"
"fmt"
"log"
"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) {
log.Printf("receipt add request: payload=%s", utils.ToLogJSON(req))
receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number)
if err != nil {
log.Printf("receipt add failed: err=%v payload=%s", err, utils.ToLogJSON(req))
return nil, err
}
receiptID, err := s.repo.Create(ctx, receipt)
if err != nil {
log.Printf("receipt persist failed: err=%v receipt=%s", err, utils.ToLogJSON(receipt))
return nil, err
}
receipt.ID = int(receiptID)
if !s.shouldCreateTransaction(req) {
log.Printf("receipt add response: payload=%s", utils.ToLogJSON(receipt))
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
log.Printf("receipt add response: payload=%s", utils.ToLogJSON(receipt))
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,
}
log.Printf("%+v\n", transaction)
log.Printf("receipt transaction create request: payload=%s", utils.ToLogJSON(transaction))
if err := s.transactionRepo.Create(ctx, transaction); err != nil {
log.Printf("receipt transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(transaction))
return nil, err
}
log.Printf("receipt transaction create response: payload=%s", utils.ToLogJSON(transaction))
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
}
+213
View File
@@ -0,0 +1,213 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context"
"database/sql"
"errors"
"fmt"
"log"
"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) {
log.Printf("transaction create request: payload=%s", utils.ToLogJSON(req))
if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" {
log.Printf("transaction create failed: err=%v payload=%s", ErrInvalidTransaction, utils.ToLogJSON(req))
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) {
log.Printf("transaction create failed: err=%v payload=%s", ErrReceiptNotFound, utils.ToLogJSON(req))
return nil, ErrReceiptNotFound
}
log.Printf("transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(req))
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,
})
}
log.Printf("transaction create response: payload=%s", utils.ToLogJSON(transaction))
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 {
+27
View File
@@ -0,0 +1,27 @@
package api
import (
"embed"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed dist
var staticFiles embed.FS
func registerStaticFiles(router *gin.Engine) {
// вырезаем префикс dist/ чтобы / отдавал index.html
distFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(distFS))
// все маршруты которые не /api и не /openapi — отдаём Vue
router.NoRoute(func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
+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)
}
}
@@ -2,6 +2,7 @@ package config
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"strings" "strings"
@@ -17,6 +18,8 @@ type Config struct {
OCRTokenPath string OCRTokenPath string
TelegramApi string
APIPort string APIPort string
APIHost string APIHost string
APISecret string APISecret string
@@ -31,7 +34,6 @@ func Load() (Config, error) {
mode := os.Getenv("RUN_MODE") mode := os.Getenv("RUN_MODE")
debugMode := os.Getenv("DEBUG_MODE") == "true" debugMode := os.Getenv("DEBUG_MODE") == "true"
botToken := os.Getenv("BOT_TOKEN") botToken := os.Getenv("BOT_TOKEN")
dbConnectionString := os.Getenv("DB_PATH")
ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
apiPort := os.Getenv("API_PORT") apiPort := os.Getenv("API_PORT")
apiHost := os.Getenv("API_HOST") apiHost := os.Getenv("API_HOST")
@@ -40,6 +42,7 @@ func Load() (Config, error) {
openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT") openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT")
runMode, err := ParseRunMode(mode) runMode, err := ParseRunMode(mode)
dbConnectionString := buildConnectionString()
if err != nil { if err != nil {
warnings = append(warnings, err.Error()) warnings = append(warnings, err.Error())
} }
@@ -53,12 +56,12 @@ 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")
} }
if dbConnectionString == "" {
dbConnectionString = "sqlite://data/app.db"
}
if apiHost == "" { if apiHost == "" {
apiHost = "localhost" apiHost = "localhost"
} }
@@ -66,7 +69,7 @@ func Load() (Config, error) {
apiPort = "8000" apiPort = "8000"
} }
if openAPIEndpoint == "" { if openAPIEndpoint == "" {
openAPIEndpoint = "/docs" openAPIEndpoint = "/openapi"
} }
} }
@@ -84,5 +87,33 @@ func Load() (Config, error) {
APISecret: apiSecret, APISecret: apiSecret,
OpenAPIEnabled: openAPIEnabled, OpenAPIEnabled: openAPIEnabled,
OpenAPIEndpoint: openAPIEndpoint, OpenAPIEndpoint: openAPIEndpoint,
TelegramApi: "https://api.telegram.org",
}, nil }, nil
} }
func buildConnectionString() string {
// если задана готовая строка — используем её (удобно для локальной разработки через .env)
if dsn := os.Getenv("DB_PATH"); dsn != "" {
return dsn
}
// собираем из отдельных переменных (для Kubernetes)
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
dbName := os.Getenv("DB_NAME")
if host == "" || user == "" || password == "" || dbName == "" {
return ""
}
if port == "" {
port = "5432"
}
return fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbName,
)
}
@@ -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,231 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"FamilyHub/src/utils"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"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")
responseBody, statusCode, err := c.doRequest(req, "familyhub_api.transactions.create", body)
if err != nil {
return err
}
if statusCode >= 300 {
return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
}
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")
responseBody, statusCode, err := c.doRequest(req, "familyhub_api.users.create", body)
if err != nil {
return err
}
if statusCode >= 300 {
return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
}
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
}
responseBody, statusCode, err := c.doRequest(req, "familyhub_api.users.by_telegram", nil)
if err != nil {
return nil, err
}
if statusCode == http.StatusNotFound {
return nil, errUserNotFound
}
if statusCode >= 300 {
return nil, fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
}
var user domain.UserResponse
if err := json.Unmarshal(responseBody, &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")
responseBody, statusCode, err := c.doRequest(req, "familyhub_api.families.create", body)
if err != nil {
return err
}
if statusCode >= 300 {
return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
}
return nil
}
func (c *HTTPClient) doRequest(req *http.Request, service string, requestBody []byte) ([]byte, int, error) {
log.Printf(
"external request: service=%s method=%s url=%s body=%q",
service,
req.Method,
req.URL.String(),
utils.TruncateForLog(string(requestBody), utils.DefaultLogValueLimit),
)
resp, err := c.client.Do(req)
if err != nil {
log.Printf("external response: service=%s method=%s url=%s err=%v", service, req.Method, req.URL.String(), err)
return nil, 0, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("external response: service=%s method=%s url=%s status=%d read_err=%v", service, req.Method, req.URL.String(), resp.StatusCode, readErr)
return nil, resp.StatusCode, readErr
}
log.Printf(
"external response: service=%s method=%s url=%s status=%d body=%q",
service,
req.Method,
req.URL.String(),
resp.StatusCode,
utils.TruncateForLog(string(responseBody), utils.DefaultLogValueLimit),
)
return responseBody, resp.StatusCode, 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,64 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/utils"
"context"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"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 {
url := c.config.TelegramApi + "/bot" + c.config.BotToken + "/sendMessage?chat_id=" + strconv.FormatInt(chatId, 10) + "&text=" + message
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
url,
nil,
)
if err != nil {
return err
}
logURL := strings.ReplaceAll(req.URL.String(), c.config.BotToken, "***")
log.Printf(
"external request: service=telegram_bot.send_message method=%s url=%s body=%q",
http.MethodGet,
logURL,
utils.TruncateForLog(fmt.Sprintf("chat_id=%d&text=%s", chatId, message), utils.DefaultLogValueLimit),
)
resp, err := c.client.Do(req)
if err != nil {
log.Printf("external response: service=telegram_bot.send_message method=%s url=%s err=%v", http.MethodGet, logURL, err)
return err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("external response: service=telegram_bot.send_message method=%s url=%s status=%d read_err=%v", http.MethodGet, logURL, resp.StatusCode, readErr)
return readErr
}
log.Printf(
"external response: service=telegram_bot.send_message method=%s url=%s status=%d body=%q",
http.MethodGet,
logURL,
resp.StatusCode,
utils.TruncateForLog(string(responseBody), utils.DefaultLogValueLimit),
)
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"`
@@ -1,9 +1,11 @@
package ocr package ocr
import ( import (
"FamilyHub/src/utils"
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"log"
vision "cloud.google.com/go/vision/apiv1" vision "cloud.google.com/go/vision/apiv1"
) )
@@ -22,23 +24,37 @@ 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()
} }
func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) { func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) {
log.Printf("external request: service=google_ocr.detect_text image_size_bytes=%d", len(image))
img, err := vision.NewImageFromReader(bytes.NewReader(image)) img, err := vision.NewImageFromReader(bytes.NewReader(image))
if err != nil { if err != nil {
log.Printf("external response: service=google_ocr.detect_text err=%v", err)
return "", fmt.Errorf("load image: %w", err) return "", fmt.Errorf("load image: %w", err)
} }
annotations, err := g.client.DetectTexts(ctx, img, nil, 1) annotations, err := g.client.DetectTexts(ctx, img, nil, 1)
if err != nil { if err != nil {
log.Printf("external response: service=google_ocr.detect_text err=%v", err)
return "", fmt.Errorf("detect text: %w", err) return "", fmt.Errorf("detect text: %w", err)
} }
if len(annotations) == 0 { if len(annotations) == 0 {
log.Printf("external response: service=google_ocr.detect_text result=%q", "")
return "", nil return "", nil
} }
log.Printf(
"external response: service=google_ocr.detect_text result=%q annotations=%d",
utils.TruncateForLog(annotations[0].Description, utils.DefaultLogValueLimit),
len(annotations),
)
return annotations[0].Description, nil return annotations[0].Description, nil
} }
@@ -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,26 @@ 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( requestBody := body.String()
log.Printf(
"external request: service=receipt_provider method=%s url=%s content_type=%s body=%q",
http.MethodPost,
url,
contentType,
utils.TruncateForLog(requestBody, utils.DefaultLogValueLimit),
)
httpReq, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
url, url,
@@ -55,25 +83,42 @@ 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.Printf("external response: service=receipt_provider method=%s url=%s err=%v", http.MethodPost, url, err)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("failed to read external service response body: %v", readErr)
return nil, readErr
}
bodyText := strings.TrimSpace(string(responseBody))
log.Printf(
"external response: service=receipt_provider method=%s url=%s status=%d body=%q",
http.MethodPost,
url,
resp.StatusCode,
utils.TruncateForLog(bodyText, utils.DefaultLogValueLimit),
)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
log.Printf("external service returned %d\n", resp.StatusCode) return nil, &ExternalServiceError{
return nil, fmt.Errorf("external service returned %d", resp.StatusCode) StatusCode: resp.StatusCode,
Body: bodyText,
}
} }
var raw struct { var raw struct {
Message map[string]interface{} `json:"message"` Message map[string]interface{} `json:"message"`
} }
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { if err := json.Unmarshal(responseBody, &raw); err != nil {
log.Printf("external service returned %s\n", err.Error()) log.Printf("external service returned invalid json: %v", err)
return nil, err return nil, err
} }
@@ -84,7 +129,8 @@ func (s *ReceiptService) GetReceipt(
} }
if receipt.IssuedAtRaw == "" { if receipt.IssuedAtRaw == "" {
return nil, errors.New("receipt not found") log.Printf("external response parse failed: service=receipt_provider err=%v date=%s number=%s", ErrReceiptNotFound, date, number)
return nil, ErrReceiptNotFound
} }
positions, err := parsePositions(receipt.PositionsRaw) positions, err := parsePositions(receipt.PositionsRaw)
@@ -119,9 +165,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
}
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"FamilyHub/src/domain" "FamilyHub/src/domain"
) )
@@ -25,29 +26,64 @@ func NewReceiptsSQLRepository(db *sql.DB) *ReceiptsSQLRepository {
} }
func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) { func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) {
log.Printf("%+v\n", receipt)
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer tx.Rollback() defer tx.Rollback()
if receipt.ReceiptNumber != receipt.UI { if receipt.ReceiptNumber != receipt.UI {
receipt.ReceiptNumber = receipt.UI receipt.ReceiptNumber = receipt.UI
} }
res, err := tx.ExecContext(ctx, `
log.Println("First query")
query := `
INSERT INTO receipts ( INSERT INTO receipts (
receipt_number, ui, status, issued_at, transaction_id,
total_amount, payment_amount, cash_amount, receipt_number,
another_amount, clearing_amount, margin, ui,
currency, payment_type, status,
cashbox_number, cashier, issued_at,
name_spd, name_to, name_np, type_np, total_amount,
street_to, house_to, payment_amount,
kod_soato, oblast_soato, rayon_soato, selsovet_soato, cash_amount,
doc_num, skno_number, unp, 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 success
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) )
`, VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14, $15,
$16, $17, $18, $19, $20,
$21, $22, $23, $24, $25,
$26, $27, $28, $29
)
RETURNING id;
`
args := []any{
receipt.TransactionID,
receipt.ReceiptNumber, receipt.ReceiptNumber,
receipt.UI, receipt.UI,
receipt.Status, receipt.Status,
@@ -84,16 +120,19 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
receipt.UNP, receipt.UNP,
receipt.Success, receipt.Success,
) }
log.Printf("SQL: %s", query)
log.Printf("ARGS: %+v", args)
var receiptID int64
err = tx.QueryRowContext(ctx, query, args...).Scan(&receiptID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
receiptID, err := res.LastInsertId() log.Println("Second query")
if err != nil {
return 0, err
}
stmt, err := tx.PrepareContext(ctx, ` stmt, err := tx.PrepareContext(ctx, `
INSERT INTO positions ( INSERT INTO positions (
@@ -108,7 +147,11 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
tag, tag,
marking_code, marking_code,
ukz_code ukz_code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) )
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10, $11
)
`) `)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -116,7 +159,8 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
defer stmt.Close() defer stmt.Close()
for _, p := range receipt.Positions { for _, p := range receipt.Positions {
_, err = stmt.ExecContext(ctx, _, err = stmt.ExecContext(
ctx,
receiptID, receiptID,
p.SectionNumber, p.SectionNumber,
p.GTINCode, p.GTINCode,
@@ -134,7 +178,11 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
} }
} }
return receiptID, tx.Commit() if err = tx.Commit(); err != nil {
return 0, err
}
return receiptID, nil
} }
func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) { func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
@@ -144,6 +192,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,
@@ -155,9 +204,10 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
doc_num, skno_number, unp, doc_num, skno_number, unp,
success success
FROM receipts FROM receipts
WHERE id = ? WHERE id = $1
`, id).Scan( `, id).Scan(
&receipt.ID, &receipt.ID,
&receipt.TransactionID,
&receipt.ReceiptNumber, &receipt.ReceiptNumber,
&receipt.UI, &receipt.UI,
&receipt.Status, &receipt.Status,
@@ -205,11 +255,12 @@ 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,
tag, marking_code, ukz_code tag, marking_code, ukz_code
FROM positions WHERE receipt_id = ? FROM positions WHERE receipt_id = $1
`, id) `, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -219,6 +270,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,10 +294,16 @@ 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 $1 OFFSET $2
`, limit, offset) `, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -255,8 +314,10 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
for rows.Next() { for rows.Next() {
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,
@@ -264,9 +325,14 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
); err != nil { ); err != nil {
return nil, err return nil, err
} }
receipts = append(receipts, &rct) receipts = append(receipts, &rct)
} }
if err := rows.Err(); err != nil {
return nil, err
}
return receipts, nil return receipts, nil
} }
@@ -280,11 +346,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
issued_at = ?, transaction_id = $1,
total_amount = ?, issued_at = $2,
currency = ? total_amount = $3,
WHERE id = ? currency = $4
WHERE id = $5
`, `,
receipt.TransactionID,
receipt.IssuedAt, receipt.IssuedAt,
receipt.TotalAmount, receipt.TotalAmount,
receipt.Currency, receipt.Currency,
@@ -294,7 +362,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
return err return err
} }
_, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = ?`, receipt.ID) _, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = $1`, receipt.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -303,7 +371,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO positions ( INSERT INTO positions (
receipt_id, product_name, product_count, amount receipt_id, product_name, product_count, amount
) VALUES (?, ?, ?, ?) ) VALUES ($1, $2, $3, $4)
`, receipt.ID, p.ProductName, p.ProductCount, p.Amount) `, receipt.ID, p.ProductName, p.ProductCount, p.Amount)
if err != nil { if err != nil {
return err return err
@@ -315,7 +383,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error { func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`DELETE FROM receipts WHERE id = ?`, `DELETE FROM receipts WHERE id = $1`,
id, id,
) )
return err return err
+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))
}
+29
View File
@@ -0,0 +1,29 @@
package utils
import (
"encoding/json"
"fmt"
)
const DefaultLogValueLimit = 4096
func ToLogJSON(value any) string {
if value == nil {
return "null"
}
data, err := json.Marshal(value)
if err != nil {
return TruncateForLog(fmt.Sprintf("%+v", value), DefaultLogValueLimit)
}
return TruncateForLog(string(data), DefaultLogValueLimit)
}
func TruncateForLog(value string, limit int) string {
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit] + "...(truncated)"
}

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