Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39425af43e | |||
| e6096c98fa | |||
| b6447cce63 | |||
| b17b43b17a | |||
| baef5a0af2 | |||
| a4f9bb63aa | |||
| 8462b16305 | |||
| c3f90b57c2 | |||
| a57f918d23 | |||
| 2dc8ff01b7 | |||
| 8e074db55f | |||
| b66be96033 | |||
| 545b05d5a0 | |||
| 6872563c62 | |||
| 4902889401 | |||
| 9d845c8899 | |||
| 48ef7217eb |
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
-1
@@ -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,
|
||||||
+1
-1
@@ -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
@@ -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"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "Семья создана успешно")
|
||||||
|
}
|
||||||
@@ -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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
func stringPtrOrNil(value string) *string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type AuthRequest struct {
|
||||||
|
TelegramId *string `json:"telegram_id"`
|
||||||
|
OTP *int64 `json:"otp"`
|
||||||
|
|
||||||
|
InitData *string `json:"init_data"`
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
+66
-23
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user