Updated API and Bot.

- added auth
- updated structure
This commit is contained in:
2026-04-01 22:11:12 +03:00
parent 418801b056
commit 48ef7217eb
54 changed files with 1839 additions and 430 deletions
+2 -1
View File
@@ -4,4 +4,5 @@
secret_key.json secret_key.json
data data
archive archive
volumes volumes
*.dtmp
+75 -1
View File
@@ -1,4 +1,4 @@
<mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.5.2 Chrome/142.0.7444.265 Electron/39.6.1 Safari/537.36" version="29.5.2"> <mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.5.2 Chrome/142.0.7444.265 Electron/39.6.1 Safari/537.36" version="29.5.2" pages="2">
<diagram name="Страница-1" id="0m6B3G-Z3EdFeOiLiUiD"> <diagram name="Страница-1" id="0m6B3G-Z3EdFeOiLiUiD">
<mxGraphModel dx="1357" dy="1036" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0"> <mxGraphModel dx="1357" dy="1036" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root> <root>
@@ -619,4 +619,78 @@
</root> </root>
</mxGraphModel> </mxGraphModel>
</diagram> </diagram>
<diagram id="8MaqrHVdWClXsSExB3yQ" name="Страница-2">
<mxGraphModel dx="1018" dy="777" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-6" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="From TG" vertex="1">
<mxGeometry height="60" width="120" x="170" y="40" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-7" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="From browser" vertex="1">
<mxGeometry height="60" width="120" x="170" y="210" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-8" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="InitDATA" vertex="1">
<mxGeometry height="30" width="60" x="440" y="40" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-9" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="telegramId" vertex="1">
<mxGeometry height="30" width="60" x="360" y="210" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-10" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="TOKEN" vertex="1">
<mxGeometry height="30" width="60" x="440" y="70" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-11" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-6" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-8" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="350" as="sourcePoint" />
<mxPoint x="550" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-12" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-10" style="endArrow=classic;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-6" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="350" as="sourcePoint" />
<mxPoint x="550" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-13" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-7" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-9" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-17" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-7">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-18" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="TG" vertex="1">
<mxGeometry height="60" width="120" x="170" y="290" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-19" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="OTP" vertex="1">
<mxGeometry height="30" width="60" x="360" y="320" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-20" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-19" style="endArrow=classic;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-18" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-21" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="OTP" vertex="1">
<mxGeometry height="30" width="60" x="460" y="225" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-22" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-7" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-21" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-23" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="Token" vertex="1">
<mxGeometry height="30" width="60" x="460" y="255" as="geometry" />
</mxCell>
<mxCell id="QpFDJFR5CK-w9ybIA2Eh-24" edge="1" parent="1" source="QpFDJFR5CK-w9ybIA2Eh-23" style="endArrow=classic;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" target="QpFDJFR5CK-w9ybIA2Eh-7" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="500" y="430" as="sourcePoint" />
<mxPoint x="550" y="380" as="targetPoint" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile> </mxfile>
+2
View File
@@ -0,0 +1,2 @@
hello:
echo "Hello world"
+65
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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;
+299
View File
@@ -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?
- [ ] Нужны ли роли внутри семьи?
+29
View File
@@ -0,0 +1,29 @@
# Бизнес процессы
## Оглавление
## Активация бота
- Пользователь активирует бота и отправляет команду */start*
- Бот стартует, присылает юзеру приветственное сообщение с информацией о том что он за бот и что он
умеет
- Пользователю становятся доступны кнопки/команды */register*, */termsOfService*, *help*.
- Прочие команды игнорируются
## Мультитенантность
### Регистрация пользователя
- По команде */register* бот идёт в апи, проверяет зарегистрирован ли пользователь и если нет то
присылает пользователю лицензионное соглашение.
- Далее появляется кнопка */getAgreement* после нажатия которой пользователь должен самостоятельно
ввести некоторый текст, который будет являться подтверждением принятия условий. в прочих ситуациях
кнопка *getAgreement* не доступна
- После успешного принятия условий бот регистрирует пользователя в системе.
- После успешной регистрации пользователю доступны команды *createFamily*, *help*, *info*
### Создание или присоединение к семейному аккаунту
- По команде *createFamily* бот проверяет есть ли у этого пользователя уже созданные семейные чаты
- если нет, то предлагает создать новый чат, запрашивает имя чата, картинку на иконку чата и создаёт
супергруппу с темами
- или предлагает присоединиться к семье, запрашивает код, который может выдать владелец семьи
+4 -8
View File
@@ -6,12 +6,16 @@ require (
cloud.google.com/go/vision v1.2.0 cloud.google.com/go/vision v1.2.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea
github.com/mattn/go-sqlite3 v1.14.34 github.com/mattn/go-sqlite3 v1.14.34
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
) )
require ( require (
@@ -28,7 +32,6 @@ require (
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -59,14 +62,8 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
@@ -91,5 +88,4 @@ require (
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
) )
+3
View File
@@ -0,0 +1,3 @@
SELECT cron.unschedule('cleanup-expired-otp');
DROP TABLE IF EXISTS otp;
+17
View File
@@ -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()$$
);
-40
View File
@@ -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),
}
}
-14
View File
@@ -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"`
}
-47
View File
@@ -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),
}
}
+26
View File
@@ -0,0 +1,26 @@
package routers
import (
"FamilyHub/src/api/services"
"github.com/gin-gonic/gin"
)
type AuthRouter struct {
service services.AuthService
}
func NewAuthRouter(s services.AuthService) *AuthRouter {
return &AuthRouter{service: s}
}
func (router *AuthRouter) RegisterRouter(r *gin.RouterGroup) {
auth := r.Group("/auth")
{
auth.POST("", router.Auth)
}
}
func (router *AuthRouter) Auth(c *gin.Context) {
}
+9 -9
View File
@@ -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
+10 -11
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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"})
} }
} }
+20 -21
View File
@@ -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
+5
View File
@@ -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,
+131
View File
@@ -0,0 +1,131 @@
package services
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
type AuthService interface {
AuthByTelegram(ctx context.Context, initData string) (string, error)
CreateOTP(ctx context.Context, telegramId int64) error
}
type authService struct {
usersRepo repositories.UsersRepository
otpRepo repositories.OTPRepository
config config.Config
jwt *utils.JWTManager
}
func NewAuthService(usersRepo repositories.UsersRepository, otpRepo repositories.OTPRepository) AuthService {
return &authService{
usersRepo: usersRepo,
otpRepo: otpRepo,
}
}
var (
ErrWrongOtp = errors.New("wrong otp")
ErrForbidden = errors.New("forbidden")
ErrUnauthorized = errors.New("unauthorized")
)
func (s *authService) AuthByTelegram(ctx context.Context, initData string) (string, error) {
data, ok := ValidateTelegramInitData(initData, s.config.BotToken)
if !ok {
return "", ErrUnauthorized
}
var user struct {
ID int64 `json:"id"`
}
err := json.Unmarshal([]byte(data["user"]), &user)
if err != nil {
return "", err
}
userModel, err := s.usersRepo.GetByTelegramID(ctx, user.ID)
if err != nil {
return "", err
}
return s.jwt.Generate(userModel.ID)
}
func (s *authService) CreateOTP(ctx context.Context, telegramId int64) error {
user, err := s.usersRepo.GetByTelegramID(ctx, telegramId)
if err != nil {
return err
}
if user == nil {
return ErrForbidden
}
b := make([]byte, 3)
if _, err = rand.Read(b); err != nil {
return err
}
code := fmt.Sprintf("%06d", (int(b[0])<<16|int(b[1])<<8|int(b[2]))%1000000)
otp := &domain.OTP{
UserID: user.ID,
Code: code,
ExpiredAt: time.Now().Add(10 * time.Minute),
}
err = s.otpRepo.Create(ctx, otp)
if err != nil {
return err
}
return nil
}
func ValidateTelegramInitData(initData string, botToken string) (map[string]string, bool) {
values, err := url.ParseQuery(initData)
if err != nil {
return nil, false
}
hash := values.Get("hash")
values.Del("hash")
var dataCheck []string
for k, v := range values {
dataCheck = append(dataCheck, k+"="+v[0])
}
sort.Strings(dataCheck)
dataCheckString := strings.Join(dataCheck, "\n")
secret := sha256.Sum256([]byte(botToken))
h := hmac.New(sha256.New, secret[:])
h.Write([]byte(dataCheckString))
expectedHash := hex.EncodeToString(h.Sum(nil))
return mapFromValues(values), expectedHash == hash
}
func mapFromValues(v url.Values) map[string]string {
m := make(map[string]string)
for k, val := range v {
m[k] = val[0]
}
return m
}
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
} }
} }
} }
-123
View File
@@ -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)
}
+94
View File
@@ -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, "Семья создана успешно")
}
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
api "FamilyHub/src/integrations/familyHub"
"FamilyHub/src/integrations/ocr"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type registrationState struct {
AgreementOffered bool
AwaitingApproval bool
}
type familyCreationState struct {
AwaitingName bool
ChatID int64
}
type Handler struct {
api *tgbotapi.BotAPI
ocr ocr.OCR
receiptApi api.ApiClient
registrationMu sync.Mutex
registrationState map[int64]registrationState
familyMu sync.Mutex
familyState map[int64]familyCreationState
}
func New(api *tgbotapi.BotAPI, ocrSvc ocr.OCR, receiptClient api.ApiClient) *Handler {
return &Handler{
api: api,
ocr: ocrSvc,
receiptApi: receiptClient,
registrationState: map[int64]registrationState{},
familyState: map[int64]familyCreationState{},
}
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (h *Handler) HandleHelp(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, "Доступные команды:\n/start\n/register\n/termsOfService\n/getAgreement\n/createFamily\n/help")
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"FamilyHub/src/domain"
"FamilyHub/src/utils"
"context"
"io"
"net/http"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandlePhoto(msg *tgbotapi.Message) {
photo := msg.Photo[len(msg.Photo)-1]
file, err := h.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
if err != nil {
h.reply(msg.Chat.ID, "Не смог получить файл 😢")
return
}
url := file.Link(h.api.Token)
resp, err := http.Get(url)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка загрузки изображения")
return
}
defer resp.Body.Close()
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка чтения изображения")
return
}
text, err := h.ocr.Recognize(context.Background(), imageBytes)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка OCR 😢")
return
}
if text == "" {
h.reply(msg.Chat.ID, "Текст не найден")
return
}
receiptMeta := utils.ExtractReceiptMeta(text)
payload := domain.AddReceiptRequest{Number: receiptMeta.ReceiptID, Date: receiptMeta.Date}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
txt, err := utils.DecodeQR(imageBytes)
println(txt)
err = h.receiptApi.SendReceipt(ctx, payload)
reply := "📄 *Результат распознавания*\n\n"
if receiptMeta.Date != "" {
reply += "📅 Дата: " + receiptMeta.Date + "\n"
} else {
reply += "📅 Дата: не найдена\n"
}
if receiptMeta.ReceiptID != "" {
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
} else {
reply += "🧾 Номер чека: не найден\n"
}
if err != nil {
reply += "Не удалось отправить чек в API " + err.Error()
} else {
reply += "Чек добавлен в базу"
}
h.replyMarkdown(msg.Chat.ID, reply)
}
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"FamilyHub/src/domain"
"context"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const agreementConfirmationText = "Я принимаю условия"
const termsOfServiceText = "Лицензионное соглашение:\n" +
"1. Вы подтверждаете согласие на обработку данных.\n" +
"2. Вы соглашаетесь с правилами использования FamilyHUB."
func (h *Handler) HandleRegister(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
registered, err := h.receiptApi.IsUserRegistered(ctx, msg.From.ID)
if err != nil {
log.Printf("failed to check registration: %v", err)
h.reply(msg.Chat.ID, "Не удалось проверить регистрацию. Попробуйте позже.")
return
}
if registered {
h.reply(msg.Chat.ID, "Ты уже зарегистрирован. Доступно: /createFamily, /help, /info")
return
}
h.setRegistrationState(msg.From.ID, registrationState{AgreementOffered: true})
h.reply(msg.Chat.ID, termsOfServiceText+"\n\nЕсли согласен, нажми /getAgreement")
}
func (h *Handler) HandleAgreementConfirmation(msg *tgbotapi.Message) {
if msg.From == nil {
return
}
if !strings.EqualFold(strings.TrimSpace(msg.Text), agreementConfirmationText) {
h.reply(msg.Chat.ID, "Фраза не совпадает. Введи точно: \"Я принимаю условия\"")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := h.receiptApi.RegisterUser(ctx, domain.CreateUserRequest{
TelegramID: msg.From.ID,
Username: stringPtrOrNil(msg.From.UserName),
FirstName: stringPtrOrNil(msg.From.FirstName),
LastName: stringPtrOrNil(msg.From.LastName),
LanguageCode: stringPtrOrNil(msg.From.LanguageCode),
})
if err != nil {
log.Printf("failed to register user: %v", err)
h.reply(msg.Chat.ID, "Не удалось завершить регистрацию. Попробуй позже.")
return
}
h.clearRegistrationState(msg.From.ID)
h.reply(msg.Chat.ID, "Регистрация завершена. Доступно: /createFamily, /help, /info")
}
func (h *Handler) HandleGetAgreement(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
state, ok := h.getRegistrationState(msg.From.ID)
if !ok || !state.AgreementOffered {
h.reply(msg.Chat.ID, "Сначала запусти /register")
return
}
state.AwaitingApproval = true
h.setRegistrationState(msg.From.ID, state)
h.reply(msg.Chat.ID, "Введи фразу для подтверждения: \"Я принимаю условия\"")
}
func (h *Handler) HandleTermsOfService(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, termsOfServiceText)
}
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"log"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) reply(chat int64, text string) {
m := tgbotapi.NewMessage(chat, text)
_, err := h.api.Send(m)
if err != nil {
log.Fatal(err)
}
}
func (h *Handler) replyMarkdown(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
h.api.Send(msg)
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (h *Handler) HandleStart(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, "Привет! Я FamilyHUB-бот. Доступно: /register, /termsOfService, /help")
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
func (h *Handler) setRegistrationState(userID int64, state registrationState) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
h.registrationState[userID] = state
}
func (h *Handler) getRegistrationState(userID int64) (registrationState, bool) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
state, ok := h.registrationState[userID]
return state, ok
}
func (h *Handler) clearRegistrationState(userID int64) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
delete(h.registrationState, userID)
}
func (h *Handler) isAwaitingAgreement(userID int64) bool {
state, ok := h.getRegistrationState(userID)
return ok && state.AwaitingApproval
}
func (h *Handler) setFamilyState(userID int64, state familyCreationState) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
h.familyState[userID] = state
}
func (h *Handler) getFamilyState(userID int64) (familyCreationState, bool) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
state, ok := h.familyState[userID]
return state, ok
}
func (h *Handler) clearFamilyState(userID int64) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
delete(h.familyState, userID)
}
func (h *Handler) isAwaitingFamilyName(userID, chatID int64) bool {
state, ok := h.getFamilyState(userID)
return ok && state.AwaitingName && state.ChatID == chatID
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandleUnknown(msg *tgbotapi.Message) {
if msg.From == nil {
return
}
text := strings.TrimSpace(msg.Text)
if text == "" || strings.HasPrefix(text, "/") {
return
}
if h.isAwaitingAgreement(msg.From.ID) {
h.HandleAgreementConfirmation(msg)
return
}
if msg.Chat != nil && h.isAwaitingFamilyName(msg.From.ID, msg.Chat.ID) {
h.handleCreateFamilyName(msg)
}
}
+9
View File
@@ -0,0 +1,9 @@
package handlers
func stringPtrOrNil(value string) *string {
if value == "" {
return nil
}
return &value
}
+40
View File
@@ -0,0 +1,40 @@
package bot
import (
"FamilyHub/src/bot/handlers"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Router struct {
handler *handlers.Handler
}
func NewRouter(handler *handlers.Handler) *Router {
return &Router{handler: handler}
}
func (r *Router) Handle(update tgbotapi.Update) {
if update.Message == nil {
return
}
switch {
case update.Message.Photo != nil:
r.handler.HandlePhoto(update.Message)
case update.Message.Text == "/start":
r.handler.HandleStart(update.Message)
case update.Message.Text == "/register":
r.handler.HandleRegister(update.Message)
case update.Message.Text == "/termsOfService":
r.handler.HandleTermsOfService(update.Message)
case update.Message.Text == "/getAgreement":
r.handler.HandleGetAgreement(update.Message)
case update.Message.Text == "/help":
r.handler.HandleHelp(update.Message)
case update.Message.Text == "/createFamily":
r.handler.HandleCreateFamily(update.Message)
default:
r.handler.HandleUnknown(update.Message)
}
}
+3
View File
@@ -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
} }
+7
View File
@@ -0,0 +1,7 @@
package domain
type AuthRequest struct {
TelegramId *string `json:"telegram_id"`
OTP *int64 `json:"otp"`
InitData *string `json:"init_data"`
}
+34
View File
@@ -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),
}
}
+9
View File
@@ -0,0 +1,9 @@
package domain
import "time"
type OTP struct {
UserID int64
Code string
ExpiredAt time.Time
}
+11
View File
@@ -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
View File
@@ -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),
}
}
+177
View File
@@ -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)
}
}
+36
View File
@@ -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
}
+26
View File
@@ -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"`
-7
View File
@@ -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
}
+63
View File
@@ -0,0 +1,63 @@
package repositories
import (
"FamilyHub/src/domain"
"context"
"database/sql"
"errors"
)
type OTPRepository interface {
Create(ctx context.Context, otp *domain.OTP) error
Get(ctx context.Context, userID int64, code string) (*domain.OTP, error)
}
type OTPSQLRepository struct {
db *sql.DB
}
func NewOTPSQLRepository(db *sql.DB) *OTPSQLRepository {
return &OTPSQLRepository{db: db}
}
func (r *OTPSQLRepository) Create(ctx context.Context, otp *domain.OTP) error {
query := `
INSERT INTO otp (user_id, otp, expired_at)
VALUES ($1, $2, $3)
`
_, err := r.db.ExecContext(ctx, query, otp.UserID, otp.Code, otp.ExpiredAt)
return err
}
func (r *OTPSQLRepository) Get(ctx context.Context, userID int64, code string) (*domain.OTP, error) {
query := `
DELETE FROM otp
WHERE ctid IN (
SELECT ctid
FROM otp
WHERE user_id = $1
AND otp = $2
AND expired_at > NOW()
ORDER BY expired_at
LIMIT 1
)
RETURNING user_id, otp, expired_at
`
var otp domain.OTP
err := r.db.QueryRowContext(ctx, query, userID, code).Scan(
&otp.UserID,
&otp.Code,
&otp.ExpiredAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &otp, nil
}
+10 -10
View File
@@ -8,10 +8,10 @@ import (
) )
type UsersRepository interface { type UsersRepository interface {
Create(ctx context.Context, user *domain.User) error Create(ctx context.Context, user *domain.UserModel) error
GetByID(ctx context.Context, id int64) (*domain.User, error) GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
Update(ctx context.Context, user *domain.User) error Update(ctx context.Context, user *domain.UserModel) error
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
} }
@@ -23,7 +23,7 @@ func NewUsersSQLRepository(db *sql.DB) *UsersSQLRepository {
return &UsersSQLRepository{db: db} return &UsersSQLRepository{db: db}
} }
func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.User) error { func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.UserModel) error {
query := ` query := `
INSERT INTO users INSERT INTO users
(telegram_id, username, first_name, last_name, language_code) (telegram_id, username, first_name, last_name, language_code)
@@ -41,7 +41,7 @@ func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.User) erro
user.LanguageCode, user.LanguageCode,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
} }
func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) { func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
query := ` query := `
SELECT SELECT
id, id,
@@ -56,7 +56,7 @@ func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Use
WHERE id = $1 WHERE id = $1
` `
var user domain.User var user domain.UserModel
err := r.db.QueryRowContext(ctx, query, id).Scan( err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.ID,
@@ -78,7 +78,7 @@ func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Use
return &user, nil return &user, nil
} }
func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) { func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
query := ` query := `
SELECT SELECT
id, id,
@@ -93,7 +93,7 @@ func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int
WHERE telegram_id = $1 WHERE telegram_id = $1
` `
var user domain.User var user domain.UserModel
err := r.db.QueryRowContext(ctx, query, telegramID).Scan( err := r.db.QueryRowContext(ctx, query, telegramID).Scan(
&user.ID, &user.ID,
@@ -115,7 +115,7 @@ func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int
return &user, nil return &user, nil
} }
func (r *UsersSQLRepository) Update(ctx context.Context, user *domain.User) error { func (r *UsersSQLRepository) Update(ctx context.Context, user *domain.UserModel) error {
query := ` query := `
UPDATE users SET UPDATE users SET
username = $1, username = $1,
+25
View File
@@ -0,0 +1,25 @@
package utils
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTManager struct {
secret string
}
func NewJWTManager(secret string) *JWTManager {
return &JWTManager{secret: secret}
}
func (j *JWTManager) Generate(userID int64) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(j.secret))
}