Updated API and Bot.
- added auth - updated structure
This commit is contained in:
+2
-1
@@ -4,4 +4,5 @@
|
|||||||
secret_key.json
|
secret_key.json
|
||||||
data
|
data
|
||||||
archive
|
archive
|
||||||
volumes
|
volumes
|
||||||
|
*.dtmp
|
||||||
+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,65 @@
|
|||||||
|
# FamilyHUB
|
||||||
|
|
||||||
|
## Заполнение конфигурации
|
||||||
|
|
||||||
|
Приложение читает переменные окружения из `.env` (через `godotenv`) и затем из окружения процесса.
|
||||||
|
|
||||||
|
### 1. Создайте файл `.env` в корне проекта
|
||||||
|
|
||||||
|
```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=/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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=/docs`
|
||||||
|
|
||||||
|
### 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 доступна на запись.
|
||||||
|
|||||||
+10
-1
@@ -2,9 +2,17 @@ version: '3.9'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/postgres-pg-cron/Dockerfile
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
restart: always
|
restart: always
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- shared_preload_libraries=pg_cron
|
||||||
|
- -c
|
||||||
|
- cron.database_name=familyHubDB
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: familyUser
|
POSTGRES_USER: familyUser
|
||||||
POSTGRES_PASSWORD: familyPass
|
POSTGRES_PASSWORD: familyPass
|
||||||
@@ -13,3 +21,4 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/postgres:/var/lib/postgresql/data
|
- ./volumes/postgres:/var/lib/postgresql/data
|
||||||
|
- ./docker/postgres-pg-cron/init:/docker-entrypoint-initdb.d
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
FROM postgres:16
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends postgresql-16-cron \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# 📘 Финансовый модуль
|
||||||
|
|
||||||
|
## 1. Общее описание
|
||||||
|
|
||||||
|
Финансовый модуль предназначен для учёта:
|
||||||
|
- доходов
|
||||||
|
- расходов
|
||||||
|
- категорий
|
||||||
|
|
||||||
|
Поддерживает два способа ввода расходов:
|
||||||
|
1. Ручной ввод
|
||||||
|
2. Сканирование чека (QR-код)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Глоссарий
|
||||||
|
|
||||||
|
**Доход (Income)**
|
||||||
|
Денежное поступление (зарплата, перевод, подарок и т.д.)
|
||||||
|
|
||||||
|
**Расход (Expense)**
|
||||||
|
Факт траты денег
|
||||||
|
|
||||||
|
**Категория (Category)**
|
||||||
|
Классификация доходов и расходов (например: еда, транспорт, зарплата)
|
||||||
|
|
||||||
|
**Чек (Receipt)**
|
||||||
|
Документ, содержащий информацию о покупке (дата, позиции, сумма)
|
||||||
|
|
||||||
|
**Позиция (Position)**
|
||||||
|
Отдельная строка в чеке (товар или услуга)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Доменная модель
|
||||||
|
|
||||||
|
### 3.1 Positions
|
||||||
|
|
||||||
|
```
|
||||||
|
positions (
|
||||||
|
id,
|
||||||
|
receipt_number,
|
||||||
|
operation_date,
|
||||||
|
gtin_code,
|
||||||
|
product_name,
|
||||||
|
product_count,
|
||||||
|
amount,
|
||||||
|
discount,
|
||||||
|
name_spd,
|
||||||
|
category_id,
|
||||||
|
family_id,
|
||||||
|
family_member_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Описание полей:**
|
||||||
|
- `receipt_number` — номер чека (nullable для ручного ввода)
|
||||||
|
- `operation_date` — дата операции
|
||||||
|
- `gtin_code` — код товара
|
||||||
|
- `product_name` — название товара
|
||||||
|
- `product_count` — количество
|
||||||
|
- `amount` — сумма позиции
|
||||||
|
- `discount` — скидка
|
||||||
|
- `name_spd` — продавец
|
||||||
|
- `category_id` — категория
|
||||||
|
- `family_id` — семья
|
||||||
|
- `family_member_id` — участник
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Receipts
|
||||||
|
|
||||||
|
```
|
||||||
|
receipts (
|
||||||
|
id,
|
||||||
|
receipt_number,
|
||||||
|
ui,
|
||||||
|
status,
|
||||||
|
issued_at,
|
||||||
|
total_amount,
|
||||||
|
payment_amount,
|
||||||
|
cash_amount,
|
||||||
|
another_amount,
|
||||||
|
clearing_amount,
|
||||||
|
margin,
|
||||||
|
currency,
|
||||||
|
payment_type,
|
||||||
|
cashbox_number,
|
||||||
|
cashier,
|
||||||
|
name_spd,
|
||||||
|
name_to,
|
||||||
|
name_np,
|
||||||
|
type_np,
|
||||||
|
street_to,
|
||||||
|
house_to,
|
||||||
|
kod_soato,
|
||||||
|
oblast_soato,
|
||||||
|
rayon_soato,
|
||||||
|
selsovet_soato,
|
||||||
|
doc_num,
|
||||||
|
skno_number,
|
||||||
|
unp,
|
||||||
|
success,
|
||||||
|
family_id,
|
||||||
|
family_member_id,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Описание:**
|
||||||
|
- хранит агрегированную информацию о чеке
|
||||||
|
- используется при сканировании QR
|
||||||
|
- связывается с positions через `receipt_number`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Categories
|
||||||
|
|
||||||
|
```
|
||||||
|
categories (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
family_id,
|
||||||
|
family_member_id,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Описание полей:**
|
||||||
|
- `type` — income | expense
|
||||||
|
- категория принадлежит семье
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Бизнес-логика
|
||||||
|
|
||||||
|
### 4.1 Добавление расхода вручную
|
||||||
|
|
||||||
|
- пользователь вводит:
|
||||||
|
- сумму
|
||||||
|
- описание
|
||||||
|
- категорию
|
||||||
|
- создаётся запись в `positions`
|
||||||
|
- `receipt_number = NULL`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Добавление дохода
|
||||||
|
|
||||||
|
- аналогично расходу
|
||||||
|
- используется категория типа `income`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Сканирование чека
|
||||||
|
|
||||||
|
#### Поток:
|
||||||
|
1. Пользователь отправляет QR-код
|
||||||
|
2. Backend получает данные чека через внешний сервис
|
||||||
|
3. Создаётся запись в `receipts`
|
||||||
|
4. Для каждой позиции создаётся запись в `positions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Потоки
|
||||||
|
|
||||||
|
### Ручной ввод
|
||||||
|
|
||||||
|
```
|
||||||
|
User → API → positions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Доход
|
||||||
|
|
||||||
|
```
|
||||||
|
User → API → positions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API (черновик)
|
||||||
|
|
||||||
|
### Создание позиции
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /positions
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 1000,
|
||||||
|
"category_id": 1,
|
||||||
|
"description": "Продукты"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Сканирование чека
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /receipts/scan
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"qr_data": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Получение позиций
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /positions
|
||||||
|
```
|
||||||
|
|
||||||
|
Фильтры:
|
||||||
|
- дата
|
||||||
|
- категория
|
||||||
|
- тип
|
||||||
|
- family_id
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Задачи для разработки
|
||||||
|
|
||||||
|
### Этап 1 — База
|
||||||
|
|
||||||
|
- [ ] Переписать SQL-миграции (positions, receipts, categories)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 2 — Категории
|
||||||
|
|
||||||
|
- [ ] CRUD категорий
|
||||||
|
- [ ] Валидация типа (income/expense)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 3 — Позиции
|
||||||
|
|
||||||
|
- [ ] Endpoint создания позиции
|
||||||
|
- [ ] Endpoint получения списка
|
||||||
|
- [ ] Фильтрация
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 4 — Доходы/расходы
|
||||||
|
|
||||||
|
- [ ] Определение типа через категорию
|
||||||
|
- [ ] Валидация соответствия
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 5 — Чеки
|
||||||
|
|
||||||
|
- [ ] Endpoint загрузки QR
|
||||||
|
- [ ] Интеграция с сервисом чеков
|
||||||
|
- [ ] Создание receipts
|
||||||
|
- [ ] Создание positions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 6 — Telegram интеграция
|
||||||
|
|
||||||
|
- [ ] Команды добавления дохода/расхода
|
||||||
|
- [ ] Обработка QR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 7 — Дополнительно
|
||||||
|
|
||||||
|
- [ ] Автокатегоризация
|
||||||
|
- [ ] Статистика
|
||||||
|
- [ ] Лимиты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Архитектурные решения
|
||||||
|
|
||||||
|
- Position — основная сущность финансов
|
||||||
|
- Receipt — агрегат для чеков
|
||||||
|
- Категории определяют тип операции
|
||||||
|
- Поддержка multi-tenant через family_id
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Открытые вопросы
|
||||||
|
|
||||||
|
- [ ] Нужна ли мультивалютность?
|
||||||
|
- [ ] Можно ли редактировать чек?
|
||||||
|
- [ ] Как обрабатывать ошибки OCR?
|
||||||
|
- [ ] Нужны ли роли внутри семьи?
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Бизнес процессы
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
## Активация бота
|
||||||
|
|
||||||
|
- Пользователь активирует бота и отправляет команду */start*
|
||||||
|
- Бот стартует, присылает юзеру приветственное сообщение с информацией о том что он за бот и что он
|
||||||
|
умеет
|
||||||
|
- Пользователю становятся доступны кнопки/команды */register*, */termsOfService*, *help*.
|
||||||
|
- Прочие команды игнорируются
|
||||||
|
|
||||||
|
## Мультитенантность
|
||||||
|
### Регистрация пользователя
|
||||||
|
|
||||||
|
- По команде */register* бот идёт в апи, проверяет зарегистрирован ли пользователь и если нет то
|
||||||
|
присылает пользователю лицензионное соглашение.
|
||||||
|
- Далее появляется кнопка */getAgreement* после нажатия которой пользователь должен самостоятельно
|
||||||
|
ввести некоторый текст, который будет являться подтверждением принятия условий. в прочих ситуациях
|
||||||
|
кнопка *getAgreement* не доступна
|
||||||
|
- После успешного принятия условий бот регистрирует пользователя в системе.
|
||||||
|
- После успешной регистрации пользователю доступны команды *createFamily*, *help*, *info*
|
||||||
|
|
||||||
|
### Создание или присоединение к семейному аккаунту
|
||||||
|
|
||||||
|
- По команде *createFamily* бот проверяет есть ли у этого пользователя уже созданные семейные чаты
|
||||||
|
- если нет, то предлагает создать новый чат, запрашивает имя чата, картинку на иконку чата и создаёт
|
||||||
|
супергруппу с темами
|
||||||
|
- или предлагает присоединиться к семье, запрашивает код, который может выдать владелец семьи
|
||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()$$
|
||||||
|
);
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"FamilyHub/src/domain"
|
|
||||||
"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"`
|
|
||||||
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 *domain.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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type AddReceiptRequest struct {
|
|
||||||
Number string `json:"number" binding:"required,min=24,max=24"`
|
|
||||||
Date string `json:"date" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddReceiptResponse struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
Number string `json:"number"`
|
|
||||||
Date time.Time `json:"date"`
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"FamilyHub/src/domain"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
|
||||||
TelegramID int64 `json:"telegram_id" validate:"required"`
|
|
||||||
Username *string `json:"username"`
|
|
||||||
FirstName string `json:"first_name" validate:"required"`
|
|
||||||
LastName *string `json:"last_name"`
|
|
||||||
LanguageCode *string `json:"language_code"`
|
|
||||||
}
|
|
||||||
type UpdateUserRequest struct {
|
|
||||||
Username *string `json:"username"`
|
|
||||||
FirstName *string `json:"first_name"`
|
|
||||||
LastName *string `json:"last_name"`
|
|
||||||
LanguageCode *string `json:"language_code"`
|
|
||||||
}
|
|
||||||
type UserResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
TelegramID int64 `json:"telegram_id"`
|
|
||||||
Username *string `json:"username"`
|
|
||||||
FirstName string `json:"first_name"`
|
|
||||||
LastName *string `json:"last_name"`
|
|
||||||
LanguageCode *string `json:"language_code"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserErrorResponse struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (response *UserResponse) ModelToResponse(u *domain.User) UserResponse {
|
|
||||||
return UserResponse{
|
|
||||||
ID: u.ID,
|
|
||||||
TelegramID: u.TelegramID,
|
|
||||||
Username: u.Username,
|
|
||||||
FirstName: u.FirstName,
|
|
||||||
LastName: u.LastName,
|
|
||||||
LanguageCode: u.LanguageCode,
|
|
||||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
|
||||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package routers
|
package routers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"FamilyHub/src/api/dto"
|
|
||||||
"FamilyHub/src/api/services"
|
"FamilyHub/src/api/services"
|
||||||
|
"FamilyHub/src/domain"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -35,14 +35,14 @@ 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} map[string]string "invalid body"
|
||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @Failure 500 {object} map[string]string "internal server error"
|
||||||
// @Router /families [post]
|
// @Router /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, gin.H{"error": err.Error()})
|
||||||
@@ -65,13 +65,13 @@ func (router *FamiliesRouter) Create(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"
|
||||||
// @Success 200 {object} dto.FamilyResponse
|
// @Success 200 {object} domain.FamilyResponse
|
||||||
// @Failure 400 {object} map[string]string "invalid id"
|
// @Failure 400 {object} map[string]string "invalid id"
|
||||||
// @Failure 404 {object} map[string]string "family not found"
|
// @Failure 404 {object} map[string]string "family not found"
|
||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @Failure 500 {object} map[string]string "internal server error"
|
||||||
// @Router /families/{id} [get]
|
// @Router /families/{id} [get]
|
||||||
func (router *FamiliesRouter) GetByID(c *gin.Context) {
|
func (router *FamiliesRouter) GetByID(c *gin.Context) {
|
||||||
var resp dto.FamilyResponse
|
var resp domain.FamilyResponse
|
||||||
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,7 +103,7 @@ func (router *FamiliesRouter) GetByID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @Failure 500 {object} map[string]string "internal server error"
|
||||||
// @Router /families/{id} [patch]
|
// @Router /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 := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,7 +111,7 @@ func (router *FamiliesRouter) Update(c *gin.Context) {
|
|||||||
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, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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,13 +20,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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 +40,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)
|
||||||
}
|
}
|
||||||
@@ -90,7 +89,7 @@ func TestFamiliesRouter_Create(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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,7 +104,7 @@ 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)
|
||||||
return expected, nil
|
return expected, nil
|
||||||
}})
|
}})
|
||||||
@@ -212,7 +211,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,7 +229,7 @@ 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)
|
||||||
@@ -293,7 +292,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 +302,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)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
||||||
var req dto.AddReceiptRequest
|
var req domain.AddReceiptRequest
|
||||||
if err := context_.ShouldBindJSON(&req); err != nil {
|
if err := context_.ShouldBindJSON(&req); err != nil {
|
||||||
log.Println("bind error:", err)
|
log.Println("bind error:", err)
|
||||||
context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||||
@@ -53,7 +53,7 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := dto.AddReceiptResponse{
|
resp := domain.AddReceiptResponse{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Number: receipt.ReceiptNumber,
|
Number: receipt.ReceiptNumber,
|
||||||
Date: receipt.IssuedAt,
|
Date: receipt.IssuedAt,
|
||||||
|
|||||||
+37
-37
@@ -1,8 +1,8 @@
|
|||||||
package routers
|
package routers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"FamilyHub/src/api/dto"
|
|
||||||
"FamilyHub/src/api/services"
|
"FamilyHub/src/api/services"
|
||||||
|
"FamilyHub/src/domain"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -34,17 +34,17 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
|
|||||||
// @Tags Users
|
// @Tags Users
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param user body dto.CreateUserRequest true "User info"
|
// @Param user body domain.CreateUserRequest true "User info"
|
||||||
// @Success 201 {object} dto.UserResponse
|
// @Success 201 {object} domain.UserResponse
|
||||||
// @Failure 400 {object} dto.UserErrorResponse
|
// @Failure 400 {object} domain.UserErrorResponse
|
||||||
// @Failure 500 {object} dto.UserErrorResponse
|
// @Failure 500 {object} domain.UserErrorResponse
|
||||||
// @Router /users [post]
|
// @Router /users [post]
|
||||||
func (router *UsersRouter) CreateUser(c *gin.Context) {
|
func (router *UsersRouter) CreateUser(c *gin.Context) {
|
||||||
var req dto.CreateUserRequest
|
var req domain.CreateUserRequest
|
||||||
var resp dto.UserResponse
|
var resp domain.UserResponse
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,16 +64,16 @@ func (router *UsersRouter) CreateUser(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path int true "User ID"
|
// @Param id path int true "User ID"
|
||||||
// @Success 200 {object} dto.UserResponse
|
// @Success 200 {object} domain.UserResponse
|
||||||
// @Failure 400 {object} dto.UserErrorResponse "invalid id"
|
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||||
// @Router /users/{id} [get]
|
// @Router /users/{id} [get]
|
||||||
func (router *UsersRouter) GetByID(c *gin.Context) {
|
func (router *UsersRouter) GetByID(c *gin.Context) {
|
||||||
var resp dto.UserResponse
|
var resp domain.UserResponse
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,16 +93,16 @@ func (router *UsersRouter) GetByID(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param telegramId path int true "Telegram ID"
|
// @Param telegramId path int true "Telegram ID"
|
||||||
// @Success 200 {object} dto.UserResponse
|
// @Success 200 {object} domain.UserResponse
|
||||||
// @Failure 400 {object} dto.UserErrorResponse "invalid telegram id"
|
// @Failure 400 {object} domain.UserErrorResponse "invalid telegram id"
|
||||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||||
// @Router /users/by-telegram/{telegramId} [get]
|
// @Router /users/by-telegram/{telegramId} [get]
|
||||||
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
||||||
var resp dto.UserResponse
|
var resp domain.UserResponse
|
||||||
telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64)
|
telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid telegram id"})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid telegram id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,23 +122,23 @@ func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path int true "User ID"
|
// @Param id path int true "User ID"
|
||||||
// @Param user body dto.UpdateUserRequest true "Данные для обновления"
|
// @Param user body domain.UpdateUserRequest true "Данные для обновления"
|
||||||
// @Success 200 {object} dto.UserResponse
|
// @Success 200 {object} domain.UserResponse
|
||||||
// @Failure 400 {object} dto.UserErrorResponse "invalid id or invalid body"
|
// @Failure 400 {object} domain.UserErrorResponse "invalid id or invalid body"
|
||||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||||
// @Router /users/{id} [patch]
|
// @Router /users/{id} [patch]
|
||||||
func (router *UsersRouter) Update(c *gin.Context) {
|
func (router *UsersRouter) Update(c *gin.Context) {
|
||||||
var resp dto.UserResponse
|
var resp domain.UserResponse
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req dto.UpdateUserRequest
|
var req domain.UpdateUserRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,14 +159,14 @@ func (router *UsersRouter) Update(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path int true "User ID"
|
// @Param id path int true "User ID"
|
||||||
// @Success 204 {string} string "no content"
|
// @Success 204 {string} string "no content"
|
||||||
// @Failure 400 {object} dto.UserErrorResponse "invalid id"
|
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||||
// @Router /users/{id} [delete]
|
// @Router /users/{id} [delete]
|
||||||
func (router *UsersRouter) Delete(c *gin.Context) {
|
func (router *UsersRouter) Delete(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +181,12 @@ func (router *UsersRouter) Delete(c *gin.Context) {
|
|||||||
func handleError(c *gin.Context, err error) {
|
func handleError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrUserNotFound):
|
case errors.Is(err, services.ErrUserNotFound):
|
||||||
c.JSON(http.StatusNotFound, dto.UserErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusNotFound, domain.UserErrorResponse{Error: err.Error()})
|
||||||
case errors.Is(err, services.ErrInvalidPatch):
|
case errors.Is(err, services.ErrInvalidPatch):
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||||
case errors.Is(err, services.ErrTelegramIDMissing):
|
case errors.Is(err, services.ErrTelegramIDMissing):
|
||||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, dto.UserErrorResponse{Error: "internal server error"})
|
c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{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"
|
||||||
@@ -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,12 +71,12 @@ func setupUsersRouter(mock services.UserService) *gin.Engine {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func sampleUser() *domain.User {
|
func sampleUser() *domain.UserModel {
|
||||||
username := "john"
|
username := "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,
|
||||||
@@ -103,7 +102,7 @@ func TestUsersRouter_CreateUser(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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,7 +117,7 @@ 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)
|
assert.Equal(t, "John", req.FirstName)
|
||||||
return expected, nil
|
return expected, nil
|
||||||
@@ -148,7 +147,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 +161,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 +189,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
|
||||||
}})
|
}})
|
||||||
@@ -230,7 +229,7 @@ func TestUsersRouter_Update(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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 +244,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 +306,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 +316,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 +327,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
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ func NewServer(cfg config.Config) *Server {
|
|||||||
familyRouter := routers.NewFamiliesRouter(familyService)
|
familyRouter := routers.NewFamiliesRouter(familyService)
|
||||||
familyRouter.RegisterRoutes(apiV1)
|
familyRouter.RegisterRoutes(apiV1)
|
||||||
|
|
||||||
|
otpRepo := repositories.NewOTPSQLRepository(dbConn)
|
||||||
|
authService := services.NewAuthService(usersRepo, otpRepo)
|
||||||
|
authRouter := routers.NewAuthRouter(authService)
|
||||||
|
authRouter.RegisterRouter(apiV1)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
httpServer: &http.Server{
|
httpServer: &http.Server{
|
||||||
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+11
-12
@@ -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 {
|
||||||
|
|||||||
+23
-19
@@ -1,20 +1,22 @@
|
|||||||
package bot
|
package bot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"FamilyHub/src/bot/handlers"
|
||||||
"FamilyHub/src/config"
|
"FamilyHub/src/config"
|
||||||
|
"FamilyHub/src/integrations/familyHub"
|
||||||
"FamilyHub/src/integrations/ocr"
|
"FamilyHub/src/integrations/ocr"
|
||||||
"FamilyHub/src/integrations/receiptApi"
|
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
ocr ocr.OCR
|
ocr ocr.OCR
|
||||||
receiptApi receiptApi.Client
|
router *Router
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(cfg config.Config) (*Bot, error) {
|
func NewBot(cfg config.Config) (*Bot, error) {
|
||||||
@@ -23,16 +25,30 @@ func NewBot(cfg config.Config) (*Bot, error) {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
api.Debug = cfg.DebugMode
|
api.Debug = cfg.DebugMode
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ocrSvc, err := ocr.NewGoogleOCR(ctx)
|
ocrSvc, err := ocr.NewGoogleOCR(ctx)
|
||||||
receiptApi_, err := receiptApi.NewHTTPClient("http://127.0.0.1:8000")
|
|
||||||
return &Bot{api: api, ocr: ocrSvc, receiptApi: receiptApi_}, nil
|
apiHost := strings.TrimSpace(cfg.APIHost)
|
||||||
|
if apiHost == "" {
|
||||||
|
apiHost = "localhost"
|
||||||
|
}
|
||||||
|
apiPort := strings.TrimSpace(cfg.APIPort)
|
||||||
|
if apiPort == "" {
|
||||||
|
apiPort = "8000"
|
||||||
|
}
|
||||||
|
|
||||||
|
receiptAPI, err := familyHub.NewApiClient(cfg)
|
||||||
|
handler := handlers.New(api, ocrSvc, receiptAPI)
|
||||||
|
|
||||||
|
return &Bot{api: api, ocr: ocrSvc, router: NewRouter(handler)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) Start(ctx context.Context) error {
|
func (bot *Bot) Start(ctx context.Context) error {
|
||||||
u := tgbotapi.NewUpdate(0)
|
u := tgbotapi.NewUpdate(0)
|
||||||
u.Timeout = 1
|
u.Timeout = 1
|
||||||
updates := bot.api.GetUpdatesChan(u)
|
updates := bot.api.GetUpdatesChan(u)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -41,23 +57,11 @@ func (bot *Bot) Start(ctx context.Context) error {
|
|||||||
_ = bot.ocr.Close()
|
_ = bot.ocr.Close()
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case update, ok := <-updates:
|
case update, ok := <-updates:
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
bot.router.Handle(update)
|
||||||
if update.Message == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case update.Message.Photo != nil:
|
|
||||||
bot.handlePhoto(update.Message)
|
|
||||||
|
|
||||||
case update.Message.Text != "":
|
|
||||||
bot.handleMessage(update.Message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"FamilyHub/src/integrations/receiptApi"
|
|
||||||
"FamilyHub/src/utils"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (bot *Bot) handleMessage(msg *tgbotapi.Message) {
|
|
||||||
switch msg.Text {
|
|
||||||
case "/start":
|
|
||||||
bot.handleStart(msg)
|
|
||||||
case "/help":
|
|
||||||
bot.handleHelp(msg)
|
|
||||||
|
|
||||||
default:
|
|
||||||
bot.handleUnknown(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) handlePhoto(msg *tgbotapi.Message) {
|
|
||||||
photo := msg.Photo[len(msg.Photo)-1]
|
|
||||||
|
|
||||||
file, err := bot.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
|
|
||||||
if err != nil {
|
|
||||||
bot.reply(msg.Chat.ID, "Не смог получить файл 😢")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
url := file.Link(bot.api.Token)
|
|
||||||
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
bot.reply(msg.Chat.ID, "Ошибка загрузки изображения")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
imageBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
bot.reply(msg.Chat.ID, "Ошибка чтения изображения")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text, err := bot.ocr.Recognize(context.Background(), imageBytes)
|
|
||||||
if err != nil {
|
|
||||||
bot.reply(msg.Chat.ID, "Ошибка OCR 😢")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if text == "" {
|
|
||||||
bot.reply(msg.Chat.ID, "Текст не найден")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
receiptMeta := utils.ExtractReceiptMeta(text)
|
|
||||||
|
|
||||||
payload := receiptApi.ReceiptPayload{
|
|
||||||
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 = bot.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 += "Чек добавлен в базу"
|
|
||||||
}
|
|
||||||
|
|
||||||
bot.replyMarkdown(msg.Chat.ID, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) handleStart(msg *tgbotapi.Message) {
|
|
||||||
bot.reply(msg.Chat.ID, "Привет! Я Telegram-бот на Go ⚡")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) handleHelp(msg *tgbotapi.Message) {
|
|
||||||
bot.reply(msg.Chat.ID, "Доступные команды:\n/start\n/help")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) handleUnknown(msg *tgbotapi.Message) {
|
|
||||||
bot.reply(msg.Chat.ID, "Не знаю такой команды 😕")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) reply(chat int64, text string) {
|
|
||||||
m := tgbotapi.NewMessage(chat, text)
|
|
||||||
_, err := bot.api.Send(m)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) replyMarkdown(chatID int64, text string) {
|
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
|
||||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
|
||||||
bot.api.Send(msg)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{
|
||||||
|
Name: familyName,
|
||||||
|
OwnerID: user.ID,
|
||||||
|
TelegramChatID: msg.Chat.ID,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ type Config struct {
|
|||||||
|
|
||||||
OCRTokenPath string
|
OCRTokenPath string
|
||||||
|
|
||||||
|
TelegramApi string
|
||||||
|
|
||||||
APIPort string
|
APIPort string
|
||||||
APIHost string
|
APIHost string
|
||||||
APISecret string
|
APISecret string
|
||||||
@@ -84,5 +86,6 @@ func Load() (Config, error) {
|
|||||||
APISecret: apiSecret,
|
APISecret: apiSecret,
|
||||||
OpenAPIEnabled: openAPIEnabled,
|
OpenAPIEnabled: openAPIEnabled,
|
||||||
OpenAPIEndpoint: openAPIEndpoint,
|
OpenAPIEndpoint: openAPIEndpoint,
|
||||||
|
TelegramApi: "https://api.telegram.org",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type AuthRequest struct {
|
||||||
|
TelegramId *string `json:"telegram_id"`
|
||||||
|
OTP *int64 `json:"otp"`
|
||||||
|
InitData *string `json:"init_data"`
|
||||||
|
}
|
||||||
@@ -39,3 +39,37 @@ type FamilyThread struct {
|
|||||||
CreatedBy int64
|
CreatedBy int64
|
||||||
CreatedAt time.Time
|
CreatedAt 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"`
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -62,3 +62,14 @@ 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddReceiptResponse struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
}
|
||||||
|
|||||||
+45
-2
@@ -4,13 +4,56 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type UserModel struct {
|
||||||
ID int64
|
ID int64
|
||||||
TelegramID int64
|
TelegramID int64
|
||||||
Username *string
|
Username *string
|
||||||
FirstName string
|
FirstName *string
|
||||||
LastName *string
|
LastName *string
|
||||||
LanguageCode *string
|
LanguageCode *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
TelegramID int64 `json:"telegram_id" validate:"required"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
FirstName *string `json:"first_name" validate:"required"`
|
||||||
|
LastName *string `json:"last_name"`
|
||||||
|
LanguageCode *string `json:"language_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Username *string `json:"username"`
|
||||||
|
FirstName *string `json:"first_name"`
|
||||||
|
LastName *string `json:"last_name"`
|
||||||
|
LanguageCode *string `json:"language_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TelegramID int64 `json:"telegram_id"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
FirstName *string `json:"first_name"`
|
||||||
|
LastName *string `json:"last_name"`
|
||||||
|
LanguageCode *string `json:"language_code"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response *UserResponse) ModelToResponse(u *UserModel) UserResponse {
|
||||||
|
return UserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
TelegramID: u.TelegramID,
|
||||||
|
Username: u.Username,
|
||||||
|
FirstName: u.FirstName,
|
||||||
|
LastName: u.LastName,
|
||||||
|
LanguageCode: u.LanguageCode,
|
||||||
|
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package familyHub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"FamilyHub/src/config"
|
||||||
|
"FamilyHub/src/domain"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
func NewApiClient(config config.Config) (*HTTPClient, error) {
|
||||||
|
return &HTTPClient{
|
||||||
|
config: config,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
c.config.APIHost+c.config.APIPort+"/receipts",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error {
|
||||||
|
registered, err := c.IsUserRegistered(ctx, payload.TelegramID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if registered {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.RegisterUser(ctx, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) IsUserRegistered(ctx context.Context, telegramID int64) (bool, error) {
|
||||||
|
_, err := c.GetUserByTelegramID(ctx, telegramID)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, errUserNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error {
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
c.config.APIHost+c.config.APIPort+"/api/v1/users",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodGet,
|
||||||
|
c.config.APIHost+c.config.APIPort+"/api/v1/users/by-telegram/"+strconv.FormatInt(telegramID, 10),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, errUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var user domain.UserResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
c.config.APIHost+c.config.APIPort+"/api/v1/families",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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_EnsureUser_AlreadyExists(t *testing.T) {
|
||||||
|
var postCalls int32
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(domain.UserResponse{
|
||||||
|
TelegramID: 100500,
|
||||||
|
FirstName: strPtr("John"),
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||||
|
atomic.AddInt32(&postCalls, 1)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewApiClient(testConfig(ts.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||||
|
TelegramID: 100500,
|
||||||
|
FirstName: strPtr("John"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&postCalls); got != 0 {
|
||||||
|
t.Fatalf("expected no POST calls, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPClient_EnsureUser_CreateOnNotFound(t *testing.T) {
|
||||||
|
var getCalls int32
|
||||||
|
var postCalls int32
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||||
|
atomic.AddInt32(&getCalls, 1)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||||
|
atomic.AddInt32(&postCalls, 1)
|
||||||
|
var req domain.CreateUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatalf("failed to decode body: %v", err)
|
||||||
|
}
|
||||||
|
if req.TelegramID != 100500 || req.FirstName == nil || *req.FirstName != "John" {
|
||||||
|
t.Fatalf("unexpected payload: %+v", req)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewApiClient(testConfig(ts.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||||
|
TelegramID: 100500,
|
||||||
|
FirstName: strPtr("John"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&getCalls); got != 1 {
|
||||||
|
t.Fatalf("expected 1 GET call, got %d", got)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&postCalls); got != 1 {
|
||||||
|
t.Fatalf("expected 1 POST call, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPClient_EnsureUser_ReturnsErrorWhenLookupFails(t *testing.T) {
|
||||||
|
var postCalls int32
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||||
|
atomic.AddInt32(&postCalls, 1)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewApiClient(testConfig(ts.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||||
|
TelegramID: 100500,
|
||||||
|
FirstName: strPtr("John"),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&postCalls); got != 0 {
|
||||||
|
t.Fatalf("expected no POST calls, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package familyHub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"FamilyHub/src/config"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewBotClient(config config.Config) (*HTTPClient, error) {
|
||||||
|
return &HTTPClient{
|
||||||
|
config: config,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodGet,
|
||||||
|
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package familyHub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"FamilyHub/src/config"
|
||||||
|
"FamilyHub/src/domain"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiClient interface {
|
||||||
|
SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error
|
||||||
|
EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error
|
||||||
|
IsUserRegistered(ctx context.Context, telegramID int64) (bool, error)
|
||||||
|
RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error
|
||||||
|
GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error)
|
||||||
|
CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotClient interface {
|
||||||
|
SendMessage(ctx context.Context, chatId int64, message string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPClient struct {
|
||||||
|
config config.Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package receiptApi
|
package familyHub
|
||||||
|
|
||||||
type ReceiptPayload struct {
|
type ReceiptPayload struct {
|
||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package receiptApi
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type Client interface {
|
|
||||||
SendReceipt(ctx context.Context, payload ReceiptPayload) error
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package receiptApi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HTTPClient struct {
|
|
||||||
baseURL string
|
|
||||||
client *http.Client
|
|
||||||
//apiKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
|
||||||
return &HTTPClient{
|
|
||||||
baseURL: baseURL,
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 60 * time.Second,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *HTTPClient) SendReceipt(
|
|
||||||
ctx context.Context,
|
|
||||||
payload ReceiptPayload,
|
|
||||||
) error {
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
ctx,
|
|
||||||
http.MethodPost,
|
|
||||||
c.baseURL+"/receipts",
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
+10
-10
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user