Updated API and Bot.
- added auth - updated structure
This commit is contained in:
@@ -5,3 +5,4 @@ secret_key.json
|
||||
data
|
||||
archive
|
||||
volumes
|
||||
*.dtmp
|
||||
+75
-1
@@ -1,4 +1,4 @@
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.5.2 Chrome/142.0.7444.265 Electron/39.6.1 Safari/537.36" version="29.5.2">
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.5.2 Chrome/142.0.7444.265 Electron/39.6.1 Safari/537.36" version="29.5.2" pages="2">
|
||||
<diagram name="Страница-1" id="0m6B3G-Z3EdFeOiLiUiD">
|
||||
<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>
|
||||
@@ -619,4 +619,78 @@
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# FamilyHUB
|
||||
|
||||
## Заполнение конфигурации
|
||||
|
||||
Приложение читает переменные окружения из `.env` (через `godotenv`) и затем из окружения процесса.
|
||||
|
||||
### 1. Создайте файл `.env` в корне проекта
|
||||
|
||||
```env
|
||||
RUN_MODE=standalone
|
||||
DEBUG_MODE=false
|
||||
|
||||
BOT_TOKEN=123456:telegram-bot-token
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/google-credentials.json
|
||||
|
||||
DB_PATH=sqlite://data/app.db
|
||||
API_HOST=localhost
|
||||
API_PORT=8000
|
||||
API_SECRET=change-me
|
||||
|
||||
OPEN_API_ENABLED=true
|
||||
OPEN_API_ENDPOINT=/docs
|
||||
```
|
||||
|
||||
### 2. Обязательные переменные по режимам
|
||||
|
||||
`RUN_MODE` поддерживает значения:
|
||||
- `bot`
|
||||
- `api`
|
||||
- `standalone`
|
||||
|
||||
Если `RUN_MODE=bot` или `RUN_MODE=standalone`, обязательны:
|
||||
- `BOT_TOKEN`
|
||||
- `GOOGLE_APPLICATION_CREDENTIALS`
|
||||
|
||||
Если `RUN_MODE=api` или `RUN_MODE=standalone`, обязательна:
|
||||
- `API_SECRET`
|
||||
|
||||
### 3. Дефолты для API-режима
|
||||
|
||||
Если не заданы, будут использованы:
|
||||
- `DB_PATH=sqlite://data/app.db`
|
||||
- `API_HOST=localhost`
|
||||
- `API_PORT=8000`
|
||||
- `OPEN_API_ENDPOINT=/docs`
|
||||
|
||||
### 4. Описание переменных
|
||||
|
||||
- `RUN_MODE`: режим запуска (`bot`, `api`, `standalone`).
|
||||
- `DEBUG_MODE`: `true/false`.
|
||||
- `BOT_TOKEN`: токен Telegram-бота.
|
||||
- `GOOGLE_APPLICATION_CREDENTIALS`: абсолютный путь к JSON-ключу Google.
|
||||
- `DB_PATH`: строка подключения к БД (например `sqlite://data/app.db`).
|
||||
- `API_HOST`: хост API.
|
||||
- `API_PORT`: порт API.
|
||||
- `API_SECRET`: секрет API.
|
||||
- `OPEN_API_ENABLED`: включает swagger-ui endpoint (`true/false`).
|
||||
- `OPEN_API_ENDPOINT`: путь для OpenAPI endpoint (в конфиге присутствует).
|
||||
|
||||
### 5. Быстрая проверка перед запуском
|
||||
|
||||
1. Убедитесь, что `RUN_MODE` выставлен корректно.
|
||||
2. Проверьте обязательные переменные для выбранного режима.
|
||||
3. Проверьте существование файла `GOOGLE_APPLICATION_CREDENTIALS` (если включен bot).
|
||||
4. Убедитесь, что `DB_PATH` валиден и директория для SQLite доступна на запись.
|
||||
|
||||
+10
-1
@@ -2,9 +2,17 @@ version: '3.9'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/postgres-pg-cron/Dockerfile
|
||||
container_name: postgres
|
||||
restart: always
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=pg_cron
|
||||
- -c
|
||||
- cron.database_name=familyHubDB
|
||||
environment:
|
||||
POSTGRES_USER: familyUser
|
||||
POSTGRES_PASSWORD: familyPass
|
||||
@@ -13,3 +21,4 @@ services:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./volumes/postgres:/var/lib/postgresql/data
|
||||
- ./docker/postgres-pg-cron/init:/docker-entrypoint-initdb.d
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM postgres:16
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-16-cron \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
@@ -0,0 +1,299 @@
|
||||
# 📘 Финансовый модуль
|
||||
|
||||
## 1. Общее описание
|
||||
|
||||
Финансовый модуль предназначен для учёта:
|
||||
- доходов
|
||||
- расходов
|
||||
- категорий
|
||||
|
||||
Поддерживает два способа ввода расходов:
|
||||
1. Ручной ввод
|
||||
2. Сканирование чека (QR-код)
|
||||
|
||||
---
|
||||
|
||||
## 2. Глоссарий
|
||||
|
||||
**Доход (Income)**
|
||||
Денежное поступление (зарплата, перевод, подарок и т.д.)
|
||||
|
||||
**Расход (Expense)**
|
||||
Факт траты денег
|
||||
|
||||
**Категория (Category)**
|
||||
Классификация доходов и расходов (например: еда, транспорт, зарплата)
|
||||
|
||||
**Чек (Receipt)**
|
||||
Документ, содержащий информацию о покупке (дата, позиции, сумма)
|
||||
|
||||
**Позиция (Position)**
|
||||
Отдельная строка в чеке (товар или услуга)
|
||||
|
||||
---
|
||||
|
||||
## 3. Доменная модель
|
||||
|
||||
### 3.1 Positions
|
||||
|
||||
```
|
||||
positions (
|
||||
id,
|
||||
receipt_number,
|
||||
operation_date,
|
||||
gtin_code,
|
||||
product_name,
|
||||
product_count,
|
||||
amount,
|
||||
discount,
|
||||
name_spd,
|
||||
category_id,
|
||||
family_id,
|
||||
family_member_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
```
|
||||
|
||||
**Описание полей:**
|
||||
- `receipt_number` — номер чека (nullable для ручного ввода)
|
||||
- `operation_date` — дата операции
|
||||
- `gtin_code` — код товара
|
||||
- `product_name` — название товара
|
||||
- `product_count` — количество
|
||||
- `amount` — сумма позиции
|
||||
- `discount` — скидка
|
||||
- `name_spd` — продавец
|
||||
- `category_id` — категория
|
||||
- `family_id` — семья
|
||||
- `family_member_id` — участник
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Receipts
|
||||
|
||||
```
|
||||
receipts (
|
||||
id,
|
||||
receipt_number,
|
||||
ui,
|
||||
status,
|
||||
issued_at,
|
||||
total_amount,
|
||||
payment_amount,
|
||||
cash_amount,
|
||||
another_amount,
|
||||
clearing_amount,
|
||||
margin,
|
||||
currency,
|
||||
payment_type,
|
||||
cashbox_number,
|
||||
cashier,
|
||||
name_spd,
|
||||
name_to,
|
||||
name_np,
|
||||
type_np,
|
||||
street_to,
|
||||
house_to,
|
||||
kod_soato,
|
||||
oblast_soato,
|
||||
rayon_soato,
|
||||
selsovet_soato,
|
||||
doc_num,
|
||||
skno_number,
|
||||
unp,
|
||||
success,
|
||||
family_id,
|
||||
family_member_id,
|
||||
created_at
|
||||
)
|
||||
```
|
||||
|
||||
**Описание:**
|
||||
- хранит агрегированную информацию о чеке
|
||||
- используется при сканировании QR
|
||||
- связывается с positions через `receipt_number`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Categories
|
||||
|
||||
```
|
||||
categories (
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
family_id,
|
||||
family_member_id,
|
||||
created_at
|
||||
)
|
||||
```
|
||||
|
||||
**Описание полей:**
|
||||
- `type` — income | expense
|
||||
- категория принадлежит семье
|
||||
|
||||
---
|
||||
|
||||
## 4. Бизнес-логика
|
||||
|
||||
### 4.1 Добавление расхода вручную
|
||||
|
||||
- пользователь вводит:
|
||||
- сумму
|
||||
- описание
|
||||
- категорию
|
||||
- создаётся запись в `positions`
|
||||
- `receipt_number = NULL`
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Добавление дохода
|
||||
|
||||
- аналогично расходу
|
||||
- используется категория типа `income`
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Сканирование чека
|
||||
|
||||
#### Поток:
|
||||
1. Пользователь отправляет QR-код
|
||||
2. Backend получает данные чека через внешний сервис
|
||||
3. Создаётся запись в `receipts`
|
||||
4. Для каждой позиции создаётся запись в `positions`
|
||||
|
||||
---
|
||||
|
||||
## 5. Потоки
|
||||
|
||||
### Ручной ввод
|
||||
|
||||
```
|
||||
User → API → positions
|
||||
```
|
||||
|
||||
### Доход
|
||||
|
||||
```
|
||||
User → API → positions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API (черновик)
|
||||
|
||||
### Создание позиции
|
||||
|
||||
```
|
||||
POST /positions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 1000,
|
||||
"category_id": 1,
|
||||
"description": "Продукты"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Сканирование чека
|
||||
|
||||
```
|
||||
POST /receipts/scan
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"qr_data": "string"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Получение позиций
|
||||
|
||||
```
|
||||
GET /positions
|
||||
```
|
||||
|
||||
Фильтры:
|
||||
- дата
|
||||
- категория
|
||||
- тип
|
||||
- family_id
|
||||
|
||||
---
|
||||
|
||||
## 7. Задачи для разработки
|
||||
|
||||
### Этап 1 — База
|
||||
|
||||
- [ ] Переписать SQL-миграции (positions, receipts, categories)
|
||||
|
||||
---
|
||||
|
||||
### Этап 2 — Категории
|
||||
|
||||
- [ ] CRUD категорий
|
||||
- [ ] Валидация типа (income/expense)
|
||||
|
||||
---
|
||||
|
||||
### Этап 3 — Позиции
|
||||
|
||||
- [ ] Endpoint создания позиции
|
||||
- [ ] Endpoint получения списка
|
||||
- [ ] Фильтрация
|
||||
|
||||
---
|
||||
|
||||
### Этап 4 — Доходы/расходы
|
||||
|
||||
- [ ] Определение типа через категорию
|
||||
- [ ] Валидация соответствия
|
||||
|
||||
---
|
||||
|
||||
### Этап 5 — Чеки
|
||||
|
||||
- [ ] Endpoint загрузки QR
|
||||
- [ ] Интеграция с сервисом чеков
|
||||
- [ ] Создание receipts
|
||||
- [ ] Создание positions
|
||||
|
||||
---
|
||||
|
||||
### Этап 6 — Telegram интеграция
|
||||
|
||||
- [ ] Команды добавления дохода/расхода
|
||||
- [ ] Обработка QR
|
||||
|
||||
---
|
||||
|
||||
### Этап 7 — Дополнительно
|
||||
|
||||
- [ ] Автокатегоризация
|
||||
- [ ] Статистика
|
||||
- [ ] Лимиты
|
||||
|
||||
---
|
||||
|
||||
## 8. Архитектурные решения
|
||||
|
||||
- Position — основная сущность финансов
|
||||
- Receipt — агрегат для чеков
|
||||
- Категории определяют тип операции
|
||||
- Поддержка multi-tenant через family_id
|
||||
|
||||
---
|
||||
|
||||
## 9. Открытые вопросы
|
||||
|
||||
- [ ] Нужна ли мультивалютность?
|
||||
- [ ] Можно ли редактировать чек?
|
||||
- [ ] Как обрабатывать ошибки OCR?
|
||||
- [ ] Нужны ли роли внутри семьи?
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Бизнес процессы
|
||||
|
||||
## Оглавление
|
||||
|
||||
## Активация бота
|
||||
|
||||
- Пользователь активирует бота и отправляет команду */start*
|
||||
- Бот стартует, присылает юзеру приветственное сообщение с информацией о том что он за бот и что он
|
||||
умеет
|
||||
- Пользователю становятся доступны кнопки/команды */register*, */termsOfService*, *help*.
|
||||
- Прочие команды игнорируются
|
||||
|
||||
## Мультитенантность
|
||||
### Регистрация пользователя
|
||||
|
||||
- По команде */register* бот идёт в апи, проверяет зарегистрирован ли пользователь и если нет то
|
||||
присылает пользователю лицензионное соглашение.
|
||||
- Далее появляется кнопка */getAgreement* после нажатия которой пользователь должен самостоятельно
|
||||
ввести некоторый текст, который будет являться подтверждением принятия условий. в прочих ситуациях
|
||||
кнопка *getAgreement* не доступна
|
||||
- После успешного принятия условий бот регистрирует пользователя в системе.
|
||||
- После успешной регистрации пользователю доступны команды *createFamily*, *help*, *info*
|
||||
|
||||
### Создание или присоединение к семейному аккаунту
|
||||
|
||||
- По команде *createFamily* бот проверяет есть ли у этого пользователя уже созданные семейные чаты
|
||||
- если нет, то предлагает создать новый чат, запрашивает имя чата, картинку на иконку чата и создаёт
|
||||
супергруппу с темами
|
||||
- или предлагает присоединиться к семье, запрашивает код, который может выдать владелец семьи
|
||||
@@ -6,12 +6,16 @@ require (
|
||||
cloud.google.com/go/vision v1.2.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
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/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
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 (
|
||||
@@ -28,7 +32,6 @@ require (
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // 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/felixge/httpsnoop v1.0.4 // 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/quic-go/qpack v0.6.0 // 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/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/contrib/instrumentation/google.golang.org/grpc/otelgrpc 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
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
SELECT cron.unschedule('cleanup-expired-otp');
|
||||
|
||||
DROP TABLE IF EXISTS otp;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE UNLOGGED TABLE otp
|
||||
(
|
||||
user_id BIGINT NOT NULL,
|
||||
otp TEXT NOT NULL,
|
||||
expired_at TIMESTAMP NOT NULL,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_otp_user_id ON otp (user_id);
|
||||
CREATE INDEX idx_otp_expired_at ON otp (expired_at);
|
||||
|
||||
SELECT cron.schedule(
|
||||
'cleanup-expired-otp',
|
||||
'*/10 * * * *',
|
||||
$$DELETE FROM otp WHERE expired_at <= NOW()$$
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateFamilyRequest struct {
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
TelegramChatID int64 `json:"telegram_chat_id"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
}
|
||||
|
||||
type UpdateFamilyRequest struct {
|
||||
Name *string `json:"name"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
}
|
||||
|
||||
type FamilyResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
TelegramChatID int64 `json:"telegram_chat_id"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (response *FamilyResponse) ModelToResponse(f *domain.Family) FamilyResponse {
|
||||
return FamilyResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
OwnerID: f.OwnerID,
|
||||
TelegramChatID: f.TelegramChatID,
|
||||
TelegramChatName: f.TelegramChatName,
|
||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: f.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type AddReceiptRequest struct {
|
||||
Number string `json:"number" binding:"required,min=24,max=24"`
|
||||
Date string `json:"date" binding:"required"`
|
||||
}
|
||||
|
||||
type AddReceiptResponse struct {
|
||||
ID int32 `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateUserRequest struct {
|
||||
TelegramID int64 `json:"telegram_id" validate:"required"`
|
||||
Username *string `json:"username"`
|
||||
FirstName string `json:"first_name" validate:"required"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
}
|
||||
type UpdateUserRequest struct {
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
}
|
||||
type UserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Username *string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (response *UserResponse) ModelToResponse(u *domain.User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
TelegramID: u.TelegramID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
LanguageCode: u.LanguageCode,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthRouter struct {
|
||||
service services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthRouter(s services.AuthService) *AuthRouter {
|
||||
return &AuthRouter{service: s}
|
||||
}
|
||||
|
||||
func (router *AuthRouter) RegisterRouter(r *gin.RouterGroup) {
|
||||
auth := r.Group("/auth")
|
||||
{
|
||||
auth.POST("", router.Auth)
|
||||
}
|
||||
}
|
||||
|
||||
func (router *AuthRouter) Auth(c *gin.Context) {
|
||||
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -35,14 +35,14 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||
// @Tags Families
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param family body dto.CreateFamilyRequest true "Family info"
|
||||
// @Success 201 {object} dto.FamilyResponse
|
||||
// @Param family body domain.CreateFamilyRequest true "Family info"
|
||||
// @Success 201 {object} domain.FamilyResponse
|
||||
// @Failure 400 {object} map[string]string "invalid body"
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families [post]
|
||||
func (router *FamiliesRouter) Create(c *gin.Context) {
|
||||
var req dto.CreateFamilyRequest
|
||||
var resp dto.FamilyResponse
|
||||
var req domain.CreateFamilyRequest
|
||||
var resp domain.FamilyResponse
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -65,13 +65,13 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @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 404 {object} map[string]string "family not found"
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families/{id} [get]
|
||||
func (router *FamiliesRouter) GetByID(c *gin.Context) {
|
||||
var resp dto.FamilyResponse
|
||||
var resp domain.FamilyResponse
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -103,7 +103,7 @@ func (router *FamiliesRouter) GetByID(c *gin.Context) {
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families/{id} [patch]
|
||||
func (router *FamiliesRouter) Update(c *gin.Context) {
|
||||
var resp dto.FamilyResponse
|
||||
var resp domain.FamilyResponse
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -111,7 +111,7 @@ func (router *FamiliesRouter) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateFamilyRequest
|
||||
var req domain.UpdateFamilyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
@@ -21,13 +20,13 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
return m.updateFn(ctx, id, req)
|
||||
}
|
||||
@@ -90,7 +89,7 @@ func TestFamiliesRouter_Create(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")
|
||||
}})
|
||||
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) {
|
||||
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)
|
||||
return expected, nil
|
||||
}})
|
||||
@@ -212,7 +211,7 @@ func TestFamiliesRouter_Update(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
|
||||
}})
|
||||
name := "Belan Updated"
|
||||
@@ -230,7 +229,7 @@ func TestFamiliesRouter_Update(t *testing.T) {
|
||||
expected := sampleFamily()
|
||||
updatedName := "Belan Updated"
|
||||
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)
|
||||
require.NotNil(t, 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) {
|
||||
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
|
||||
}})
|
||||
|
||||
@@ -303,7 +302,7 @@ func TestFamiliesRouter_Create_ResponseShape(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp dto.FamilyResponse
|
||||
var resp domain.FamilyResponse
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected.ID, resp.ID)
|
||||
|
||||
@@ -31,7 +31,7 @@ func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
||||
var req dto.AddReceiptRequest
|
||||
var req domain.AddReceiptRequest
|
||||
if err := context_.ShouldBindJSON(&req); err != nil {
|
||||
log.Println("bind error:", err)
|
||||
context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
@@ -53,7 +53,7 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := dto.AddReceiptResponse{
|
||||
resp := domain.AddReceiptResponse{
|
||||
ID: 1,
|
||||
Number: receipt.ReceiptNumber,
|
||||
Date: receipt.IssuedAt,
|
||||
|
||||
+37
-37
@@ -1,8 +1,8 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -34,17 +34,17 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body dto.CreateUserRequest true "User info"
|
||||
// @Success 201 {object} dto.UserResponse
|
||||
// @Failure 400 {object} dto.UserErrorResponse
|
||||
// @Failure 500 {object} dto.UserErrorResponse
|
||||
// @Param user body domain.CreateUserRequest true "User info"
|
||||
// @Success 201 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse
|
||||
// @Failure 500 {object} domain.UserErrorResponse
|
||||
// @Router /users [post]
|
||||
func (router *UsersRouter) CreateUser(c *gin.Context) {
|
||||
var req dto.CreateUserRequest
|
||||
var resp dto.UserResponse
|
||||
var req domain.CreateUserRequest
|
||||
var resp domain.UserResponse
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -64,16 +64,16 @@ func (router *UsersRouter) CreateUser(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 400 {object} dto.UserErrorResponse "invalid id"
|
||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
||||
// @Success 200 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/{id} [get]
|
||||
func (router *UsersRouter) GetByID(c *gin.Context) {
|
||||
var resp dto.UserResponse
|
||||
var resp domain.UserResponse
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"})
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,16 +93,16 @@ func (router *UsersRouter) GetByID(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param telegramId path int true "Telegram ID"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 400 {object} dto.UserErrorResponse "invalid telegram id"
|
||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
||||
// @Success 200 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid telegram id"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/by-telegram/{telegramId} [get]
|
||||
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
||||
var resp dto.UserResponse
|
||||
var resp domain.UserResponse
|
||||
telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid telegram id"})
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid telegram id"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,23 +122,23 @@ func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Param user body dto.UpdateUserRequest true "Данные для обновления"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 400 {object} dto.UserErrorResponse "invalid id or invalid body"
|
||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
||||
// @Param user body domain.UpdateUserRequest true "Данные для обновления"
|
||||
// @Success 200 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id or invalid body"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/{id} [patch]
|
||||
func (router *UsersRouter) Update(c *gin.Context) {
|
||||
var resp dto.UserResponse
|
||||
var resp domain.UserResponse
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"})
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateUserRequest
|
||||
var req domain.UpdateUserRequest
|
||||
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
|
||||
}
|
||||
|
||||
@@ -159,14 +159,14 @@ func (router *UsersRouter) Update(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {object} dto.UserErrorResponse "invalid id"
|
||||
// @Failure 404 {object} dto.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} dto.UserErrorResponse "internal server error"
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/{id} [delete]
|
||||
func (router *UsersRouter) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"})
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,12 +181,12 @@ func (router *UsersRouter) Delete(c *gin.Context) {
|
||||
func handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
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):
|
||||
c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()})
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||
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:
|
||||
c.JSON(http.StatusInternalServerError, dto.UserErrorResponse{Error: "internal server error"})
|
||||
c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
@@ -21,35 +20,35 @@ import (
|
||||
)
|
||||
|
||||
type userServiceMock struct {
|
||||
createFn func(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error)
|
||||
getByIDFn func(ctx context.Context, id int64) (*domain.User, error)
|
||||
getByTelegramIDFn func(ctx context.Context, telegramID int64) (*domain.User, error)
|
||||
updateFn func(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error)
|
||||
createFn func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error)
|
||||
getByIDFn func(ctx context.Context, id int64) (*domain.UserModel, error)
|
||||
getByTelegramIDFn func(ctx context.Context, telegramID int64) (*domain.UserModel, error)
|
||||
updateFn func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, 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 {
|
||||
return m.createFn(ctx, req)
|
||||
}
|
||||
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 {
|
||||
return m.getByIDFn(ctx, id)
|
||||
}
|
||||
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 {
|
||||
return m.getByTelegramIDFn(ctx, telegramID)
|
||||
}
|
||||
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 {
|
||||
return m.updateFn(ctx, id, req)
|
||||
}
|
||||
@@ -72,12 +71,12 @@ func setupUsersRouter(mock services.UserService) *gin.Engine {
|
||||
return r
|
||||
}
|
||||
|
||||
func sampleUser() *domain.User {
|
||||
func sampleUser() *domain.UserModel {
|
||||
username := "john"
|
||||
lastName := "Doe"
|
||||
languageCode := "en"
|
||||
|
||||
return &domain.User{
|
||||
return &domain.UserModel{
|
||||
ID: 10,
|
||||
TelegramID: 100500,
|
||||
Username: &username,
|
||||
@@ -103,7 +102,7 @@ func TestUsersRouter_CreateUser(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
|
||||
}})
|
||||
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) {
|
||||
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, "John", req.FirstName)
|
||||
return expected, nil
|
||||
@@ -148,7 +147,7 @@ func TestUsersRouter_GetByID(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
|
||||
}})
|
||||
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) {
|
||||
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)
|
||||
return expected, nil
|
||||
}})
|
||||
@@ -190,7 +189,7 @@ func TestUsersRouter_GetByTelegramID(t *testing.T) {
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
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)
|
||||
return expected, nil
|
||||
}})
|
||||
@@ -230,7 +229,7 @@ func TestUsersRouter_Update(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
|
||||
}})
|
||||
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) {
|
||||
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)
|
||||
require.NotNil(t, 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) {
|
||||
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
|
||||
}})
|
||||
|
||||
@@ -317,7 +316,7 @@ func TestUsersRouter_CreateUser_ResponseShape(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp dto.UserResponse
|
||||
var resp domain.UserResponse
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
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)
|
||||
u := sampleUser()
|
||||
u.ID = id
|
||||
|
||||
@@ -56,6 +56,11 @@ func NewServer(cfg config.Config) *Server {
|
||||
familyRouter := routers.NewFamiliesRouter(familyService)
|
||||
familyRouter.RegisterRoutes(apiV1)
|
||||
|
||||
otpRepo := repositories.NewOTPSQLRepository(dbConn)
|
||||
authService := services.NewAuthService(usersRepo, otpRepo)
|
||||
authRouter := routers.NewAuthRouter(authService)
|
||||
authRouter.RegisterRouter(apiV1)
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"FamilyHub/src/utils"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
AuthByTelegram(ctx context.Context, initData string) (string, error)
|
||||
CreateOTP(ctx context.Context, telegramId int64) error
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
usersRepo repositories.UsersRepository
|
||||
otpRepo repositories.OTPRepository
|
||||
config config.Config
|
||||
jwt *utils.JWTManager
|
||||
}
|
||||
|
||||
func NewAuthService(usersRepo repositories.UsersRepository, otpRepo repositories.OTPRepository) AuthService {
|
||||
return &authService{
|
||||
usersRepo: usersRepo,
|
||||
otpRepo: otpRepo,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrWrongOtp = errors.New("wrong otp")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
)
|
||||
|
||||
func (s *authService) AuthByTelegram(ctx context.Context, initData string) (string, error) {
|
||||
data, ok := ValidateTelegramInitData(initData, s.config.BotToken)
|
||||
if !ok {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(data["user"]), &user)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userModel, err := s.usersRepo.GetByTelegramID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.jwt.Generate(userModel.ID)
|
||||
}
|
||||
|
||||
func (s *authService) CreateOTP(ctx context.Context, telegramId int64) error {
|
||||
user, err := s.usersRepo.GetByTelegramID(ctx, telegramId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
b := make([]byte, 3)
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := fmt.Sprintf("%06d", (int(b[0])<<16|int(b[1])<<8|int(b[2]))%1000000)
|
||||
otp := &domain.OTP{
|
||||
UserID: user.ID,
|
||||
Code: code,
|
||||
ExpiredAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
err = s.otpRepo.Create(ctx, otp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTelegramInitData(initData string, botToken string) (map[string]string, bool) {
|
||||
values, err := url.ParseQuery(initData)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
hash := values.Get("hash")
|
||||
values.Del("hash")
|
||||
|
||||
var dataCheck []string
|
||||
for k, v := range values {
|
||||
dataCheck = append(dataCheck, k+"="+v[0])
|
||||
}
|
||||
|
||||
sort.Strings(dataCheck)
|
||||
dataCheckString := strings.Join(dataCheck, "\n")
|
||||
|
||||
secret := sha256.Sum256([]byte(botToken))
|
||||
h := hmac.New(sha256.New, secret[:])
|
||||
h.Write([]byte(dataCheckString))
|
||||
|
||||
expectedHash := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
return mapFromValues(values), expectedHash == hash
|
||||
}
|
||||
|
||||
func mapFromValues(v url.Values) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for k, val := range v {
|
||||
m[k] = val[0]
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"context"
|
||||
@@ -9,9 +8,9 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -27,7 +26,7 @@ var (
|
||||
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{
|
||||
Name: req.Name,
|
||||
OwnerID: req.OwnerID,
|
||||
@@ -50,7 +49,7 @@ func (s *familyService) GetByID(ctx context.Context, id int64) (*domain.Family,
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+11
-12
@@ -1,7 +1,6 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"context"
|
||||
@@ -9,10 +8,10 @@ import (
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error)
|
||||
GetByID(ctx context.Context, id int64) (*domain.User, error)
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error)
|
||||
Update(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error)
|
||||
Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error)
|
||||
GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
|
||||
Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error)
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
@@ -30,8 +29,8 @@ var (
|
||||
ErrTelegramIDMissing = errors.New("telegram_id is required")
|
||||
)
|
||||
|
||||
func (s *userService) Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) {
|
||||
user_ := &domain.User{
|
||||
func (s *userService) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
|
||||
user_ := &domain.UserModel{
|
||||
TelegramID: req.TelegramID,
|
||||
Username: req.Username,
|
||||
FirstName: req.FirstName,
|
||||
@@ -45,7 +44,7 @@ func (s *userService) Create(ctx context.Context, req dto.CreateUserRequest) (*d
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -55,7 +54,7 @@ func (s *userService) GetByID(ctx context.Context, id int64) (*domain.User, erro
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,7 +65,7 @@ func (s *userService) GetByTelegramID(ctx context.Context, telegramID int64) (*d
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -82,10 +81,10 @@ func (s *userService) Update(ctx context.Context, id int64, req dto.UpdateUserRe
|
||||
return nil, ErrInvalidPatch
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, &domain.User{
|
||||
if err := s.repo.Update(ctx, &domain.UserModel{
|
||||
ID: id,
|
||||
Username: req.Username,
|
||||
FirstName: *req.FirstName,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
LanguageCode: req.LanguageCode,
|
||||
}); err != nil {
|
||||
|
||||
+21
-17
@@ -1,11 +1,13 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"FamilyHub/src/bot/handlers"
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/integrations/familyHub"
|
||||
"FamilyHub/src/integrations/ocr"
|
||||
"FamilyHub/src/integrations/receiptApi"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
@@ -14,7 +16,7 @@ import (
|
||||
type Bot struct {
|
||||
api *tgbotapi.BotAPI
|
||||
ocr ocr.OCR
|
||||
receiptApi receiptApi.Client
|
||||
router *Router
|
||||
}
|
||||
|
||||
func NewBot(cfg config.Config) (*Bot, error) {
|
||||
@@ -23,16 +25,30 @@ func NewBot(cfg config.Config) (*Bot, error) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
api.Debug = cfg.DebugMode
|
||||
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 1
|
||||
updates := bot.api.GetUpdatesChan(u)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -41,23 +57,11 @@ func (bot *Bot) Start(ctx context.Context) error {
|
||||
_ = bot.ocr.Close()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case update.Message.Photo != nil:
|
||||
bot.handlePhoto(update.Message)
|
||||
|
||||
case update.Message.Text != "":
|
||||
bot.handleMessage(update.Message)
|
||||
}
|
||||
bot.router.Handle(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"FamilyHub/src/integrations/receiptApi"
|
||||
"FamilyHub/src/utils"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (bot *Bot) handleMessage(msg *tgbotapi.Message) {
|
||||
switch msg.Text {
|
||||
case "/start":
|
||||
bot.handleStart(msg)
|
||||
case "/help":
|
||||
bot.handleHelp(msg)
|
||||
|
||||
default:
|
||||
bot.handleUnknown(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (bot *Bot) handlePhoto(msg *tgbotapi.Message) {
|
||||
photo := msg.Photo[len(msg.Photo)-1]
|
||||
|
||||
file, err := bot.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
|
||||
if err != nil {
|
||||
bot.reply(msg.Chat.ID, "Не смог получить файл 😢")
|
||||
return
|
||||
}
|
||||
|
||||
url := file.Link(bot.api.Token)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
bot.reply(msg.Chat.ID, "Ошибка загрузки изображения")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
bot.reply(msg.Chat.ID, "Ошибка чтения изображения")
|
||||
return
|
||||
}
|
||||
|
||||
text, err := bot.ocr.Recognize(context.Background(), imageBytes)
|
||||
if err != nil {
|
||||
bot.reply(msg.Chat.ID, "Ошибка OCR 😢")
|
||||
return
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
bot.reply(msg.Chat.ID, "Текст не найден")
|
||||
return
|
||||
}
|
||||
|
||||
receiptMeta := utils.ExtractReceiptMeta(text)
|
||||
|
||||
payload := receiptApi.ReceiptPayload{
|
||||
Number: receiptMeta.ReceiptID,
|
||||
Date: receiptMeta.Date,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
txt, err := utils.DecodeQR(imageBytes)
|
||||
println(txt)
|
||||
|
||||
err = bot.receiptApi.SendReceipt(ctx, payload)
|
||||
|
||||
reply := "📄 *Результат распознавания*\n\n"
|
||||
|
||||
if receiptMeta.Date != "" {
|
||||
reply += "📅 Дата: " + receiptMeta.Date + "\n"
|
||||
} else {
|
||||
reply += "📅 Дата: не найдена\n"
|
||||
}
|
||||
|
||||
if receiptMeta.ReceiptID != "" {
|
||||
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
|
||||
} else {
|
||||
reply += "🧾 Номер чека: не найден\n"
|
||||
}
|
||||
if err != nil {
|
||||
reply += "Не удалось отправить чек в API " + err.Error()
|
||||
} else {
|
||||
reply += "Чек добавлен в базу"
|
||||
}
|
||||
|
||||
bot.replyMarkdown(msg.Chat.ID, reply)
|
||||
}
|
||||
|
||||
func (bot *Bot) handleStart(msg *tgbotapi.Message) {
|
||||
bot.reply(msg.Chat.ID, "Привет! Я Telegram-бот на Go ⚡")
|
||||
}
|
||||
|
||||
func (bot *Bot) handleHelp(msg *tgbotapi.Message) {
|
||||
bot.reply(msg.Chat.ID, "Доступные команды:\n/start\n/help")
|
||||
}
|
||||
|
||||
func (bot *Bot) handleUnknown(msg *tgbotapi.Message) {
|
||||
bot.reply(msg.Chat.ID, "Не знаю такой команды 😕")
|
||||
}
|
||||
|
||||
func (bot *Bot) reply(chat int64, text string) {
|
||||
m := tgbotapi.NewMessage(chat, text)
|
||||
_, err := bot.api.Send(m)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (bot *Bot) replyMarkdown(chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||
bot.api.Send(msg)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) HandleCreateFamily(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Chat == nil || msg.Chat.Type != "supergroup" {
|
||||
h.reply(msg.Chat.ID, "Для создания семьи переведи бота в супергруппу и запусти /createFamily там")
|
||||
return
|
||||
}
|
||||
|
||||
h.setFamilyState(msg.From.ID, familyCreationState{AwaitingName: true, ChatID: msg.Chat.ID})
|
||||
h.reply(msg.Chat.ID, "Введи имя семьи одним сообщением")
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateFamilyName(msg *tgbotapi.Message) {
|
||||
if msg.From == nil || msg.Chat == nil {
|
||||
return
|
||||
}
|
||||
|
||||
familyName := strings.TrimSpace(msg.Text)
|
||||
if familyName == "" {
|
||||
h.reply(msg.Chat.ID, "Имя семьи не может быть пустым. Введи имя еще раз")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := h.receiptApi.GetUserByTelegramID(ctx, msg.From.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserNotFound) {
|
||||
h.reply(msg.Chat.ID, "Сначала зарегистрируйся: /register")
|
||||
return
|
||||
}
|
||||
log.Printf("failed to get user by telegram id: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось получить пользователя приложения")
|
||||
return
|
||||
}
|
||||
|
||||
promoteCfg := tgbotapi.PromoteChatMemberConfig{
|
||||
ChatMemberConfig: tgbotapi.ChatMemberConfig{
|
||||
ChatID: msg.Chat.ID,
|
||||
UserID: msg.From.ID,
|
||||
},
|
||||
CanManageChat: true,
|
||||
CanChangeInfo: true,
|
||||
CanDeleteMessages: true,
|
||||
CanManageVoiceChats: true,
|
||||
CanInviteUsers: true,
|
||||
CanRestrictMembers: true,
|
||||
CanPinMessages: true,
|
||||
}
|
||||
if _, err := h.api.Request(promoteCfg); err != nil {
|
||||
log.Printf("failed to promote user to admin: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось назначить тебя администратором. Проверь права бота")
|
||||
return
|
||||
}
|
||||
|
||||
chatName := msg.Chat.Title
|
||||
if strings.TrimSpace(chatName) == "" {
|
||||
chatName = familyName
|
||||
}
|
||||
|
||||
err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{
|
||||
Name: familyName,
|
||||
OwnerID: user.ID,
|
||||
TelegramChatID: msg.Chat.ID,
|
||||
TelegramChatName: chatName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("failed to create family in api: %v", err)
|
||||
h.reply(msg.Chat.ID, fmt.Sprintf("Не удалось создать семью в API: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
h.clearFamilyState(msg.From.ID)
|
||||
h.reply(msg.Chat.ID, "Семья создана успешно")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
api "FamilyHub/src/integrations/familyHub"
|
||||
"FamilyHub/src/integrations/ocr"
|
||||
"sync"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type registrationState struct {
|
||||
AgreementOffered bool
|
||||
AwaitingApproval bool
|
||||
}
|
||||
|
||||
type familyCreationState struct {
|
||||
AwaitingName bool
|
||||
ChatID int64
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
api *tgbotapi.BotAPI
|
||||
ocr ocr.OCR
|
||||
receiptApi api.ApiClient
|
||||
|
||||
registrationMu sync.Mutex
|
||||
registrationState map[int64]registrationState
|
||||
|
||||
familyMu sync.Mutex
|
||||
familyState map[int64]familyCreationState
|
||||
}
|
||||
|
||||
func New(api *tgbotapi.BotAPI, ocrSvc ocr.OCR, receiptClient api.ApiClient) *Handler {
|
||||
return &Handler{
|
||||
api: api,
|
||||
ocr: ocrSvc,
|
||||
receiptApi: receiptClient,
|
||||
registrationState: map[int64]registrationState{},
|
||||
familyState: map[int64]familyCreationState{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
func (h *Handler) HandleHelp(msg *tgbotapi.Message) {
|
||||
h.reply(msg.Chat.ID, "Доступные команды:\n/start\n/register\n/termsOfService\n/getAgreement\n/createFamily\n/help")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/utils"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) HandlePhoto(msg *tgbotapi.Message) {
|
||||
photo := msg.Photo[len(msg.Photo)-1]
|
||||
|
||||
file, err := h.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Не смог получить файл 😢")
|
||||
return
|
||||
}
|
||||
|
||||
url := file.Link(h.api.Token)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Ошибка загрузки изображения")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Ошибка чтения изображения")
|
||||
return
|
||||
}
|
||||
|
||||
text, err := h.ocr.Recognize(context.Background(), imageBytes)
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Ошибка OCR 😢")
|
||||
return
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
h.reply(msg.Chat.ID, "Текст не найден")
|
||||
return
|
||||
}
|
||||
|
||||
receiptMeta := utils.ExtractReceiptMeta(text)
|
||||
payload := domain.AddReceiptRequest{Number: receiptMeta.ReceiptID, Date: receiptMeta.Date}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
txt, err := utils.DecodeQR(imageBytes)
|
||||
println(txt)
|
||||
|
||||
err = h.receiptApi.SendReceipt(ctx, payload)
|
||||
|
||||
reply := "📄 *Результат распознавания*\n\n"
|
||||
if receiptMeta.Date != "" {
|
||||
reply += "📅 Дата: " + receiptMeta.Date + "\n"
|
||||
} else {
|
||||
reply += "📅 Дата: не найдена\n"
|
||||
}
|
||||
if receiptMeta.ReceiptID != "" {
|
||||
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
|
||||
} else {
|
||||
reply += "🧾 Номер чека: не найден\n"
|
||||
}
|
||||
if err != nil {
|
||||
reply += "Не удалось отправить чек в API " + err.Error()
|
||||
} else {
|
||||
reply += "Чек добавлен в базу"
|
||||
}
|
||||
|
||||
h.replyMarkdown(msg.Chat.ID, reply)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
const agreementConfirmationText = "Я принимаю условия"
|
||||
|
||||
const termsOfServiceText = "Лицензионное соглашение:\n" +
|
||||
"1. Вы подтверждаете согласие на обработку данных.\n" +
|
||||
"2. Вы соглашаетесь с правилами использования FamilyHUB."
|
||||
|
||||
func (h *Handler) HandleRegister(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
registered, err := h.receiptApi.IsUserRegistered(ctx, msg.From.ID)
|
||||
if err != nil {
|
||||
log.Printf("failed to check registration: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось проверить регистрацию. Попробуйте позже.")
|
||||
return
|
||||
}
|
||||
|
||||
if registered {
|
||||
h.reply(msg.Chat.ID, "Ты уже зарегистрирован. Доступно: /createFamily, /help, /info")
|
||||
return
|
||||
}
|
||||
|
||||
h.setRegistrationState(msg.From.ID, registrationState{AgreementOffered: true})
|
||||
h.reply(msg.Chat.ID, termsOfServiceText+"\n\nЕсли согласен, нажми /getAgreement")
|
||||
}
|
||||
func (h *Handler) HandleAgreementConfirmation(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(msg.Text), agreementConfirmationText) {
|
||||
h.reply(msg.Chat.ID, "Фраза не совпадает. Введи точно: \"Я принимаю условия\"")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.receiptApi.RegisterUser(ctx, domain.CreateUserRequest{
|
||||
TelegramID: msg.From.ID,
|
||||
Username: stringPtrOrNil(msg.From.UserName),
|
||||
FirstName: stringPtrOrNil(msg.From.FirstName),
|
||||
LastName: stringPtrOrNil(msg.From.LastName),
|
||||
LanguageCode: stringPtrOrNil(msg.From.LanguageCode),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("failed to register user: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось завершить регистрацию. Попробуй позже.")
|
||||
return
|
||||
}
|
||||
|
||||
h.clearRegistrationState(msg.From.ID)
|
||||
h.reply(msg.Chat.ID, "Регистрация завершена. Доступно: /createFamily, /help, /info")
|
||||
}
|
||||
func (h *Handler) HandleGetAgreement(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := h.getRegistrationState(msg.From.ID)
|
||||
if !ok || !state.AgreementOffered {
|
||||
h.reply(msg.Chat.ID, "Сначала запусти /register")
|
||||
return
|
||||
}
|
||||
|
||||
state.AwaitingApproval = true
|
||||
h.setRegistrationState(msg.From.ID, state)
|
||||
h.reply(msg.Chat.ID, "Введи фразу для подтверждения: \"Я принимаю условия\"")
|
||||
}
|
||||
func (h *Handler) HandleTermsOfService(msg *tgbotapi.Message) {
|
||||
h.reply(msg.Chat.ID, termsOfServiceText)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) reply(chat int64, text string) {
|
||||
m := tgbotapi.NewMessage(chat, text)
|
||||
_, err := h.api.Send(m)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) replyMarkdown(chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||
h.api.Send(msg)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
func (h *Handler) HandleStart(msg *tgbotapi.Message) {
|
||||
h.reply(msg.Chat.ID, "Привет! Я FamilyHUB-бот. Доступно: /register, /termsOfService, /help")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
func (h *Handler) setRegistrationState(userID int64, state registrationState) {
|
||||
h.registrationMu.Lock()
|
||||
defer h.registrationMu.Unlock()
|
||||
h.registrationState[userID] = state
|
||||
}
|
||||
|
||||
func (h *Handler) getRegistrationState(userID int64) (registrationState, bool) {
|
||||
h.registrationMu.Lock()
|
||||
defer h.registrationMu.Unlock()
|
||||
state, ok := h.registrationState[userID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (h *Handler) clearRegistrationState(userID int64) {
|
||||
h.registrationMu.Lock()
|
||||
defer h.registrationMu.Unlock()
|
||||
delete(h.registrationState, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) isAwaitingAgreement(userID int64) bool {
|
||||
state, ok := h.getRegistrationState(userID)
|
||||
return ok && state.AwaitingApproval
|
||||
}
|
||||
|
||||
func (h *Handler) setFamilyState(userID int64, state familyCreationState) {
|
||||
h.familyMu.Lock()
|
||||
defer h.familyMu.Unlock()
|
||||
h.familyState[userID] = state
|
||||
}
|
||||
|
||||
func (h *Handler) getFamilyState(userID int64) (familyCreationState, bool) {
|
||||
h.familyMu.Lock()
|
||||
defer h.familyMu.Unlock()
|
||||
state, ok := h.familyState[userID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (h *Handler) clearFamilyState(userID int64) {
|
||||
h.familyMu.Lock()
|
||||
defer h.familyMu.Unlock()
|
||||
delete(h.familyState, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) isAwaitingFamilyName(userID, chatID int64) bool {
|
||||
state, ok := h.getFamilyState(userID)
|
||||
return ok && state.AwaitingName && state.ChatID == chatID
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) HandleUnknown(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(msg.Text)
|
||||
if text == "" || strings.HasPrefix(text, "/") {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isAwaitingAgreement(msg.From.ID) {
|
||||
h.HandleAgreementConfirmation(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Chat != nil && h.isAwaitingFamilyName(msg.From.ID, msg.Chat.ID) {
|
||||
h.handleCreateFamilyName(msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package handlers
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"FamilyHub/src/bot/handlers"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
handler *handlers.Handler
|
||||
}
|
||||
|
||||
func NewRouter(handler *handlers.Handler) *Router {
|
||||
return &Router{handler: handler}
|
||||
}
|
||||
|
||||
func (r *Router) Handle(update tgbotapi.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case update.Message.Photo != nil:
|
||||
r.handler.HandlePhoto(update.Message)
|
||||
case update.Message.Text == "/start":
|
||||
r.handler.HandleStart(update.Message)
|
||||
case update.Message.Text == "/register":
|
||||
r.handler.HandleRegister(update.Message)
|
||||
case update.Message.Text == "/termsOfService":
|
||||
r.handler.HandleTermsOfService(update.Message)
|
||||
case update.Message.Text == "/getAgreement":
|
||||
r.handler.HandleGetAgreement(update.Message)
|
||||
case update.Message.Text == "/help":
|
||||
r.handler.HandleHelp(update.Message)
|
||||
case update.Message.Text == "/createFamily":
|
||||
r.handler.HandleCreateFamily(update.Message)
|
||||
default:
|
||||
r.handler.HandleUnknown(update.Message)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type Config struct {
|
||||
|
||||
OCRTokenPath string
|
||||
|
||||
TelegramApi string
|
||||
|
||||
APIPort string
|
||||
APIHost string
|
||||
APISecret string
|
||||
@@ -84,5 +86,6 @@ func Load() (Config, error) {
|
||||
APISecret: apiSecret,
|
||||
OpenAPIEnabled: openAPIEnabled,
|
||||
OpenAPIEndpoint: openAPIEndpoint,
|
||||
TelegramApi: "https://api.telegram.org",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package domain
|
||||
|
||||
type AuthRequest struct {
|
||||
TelegramId *string `json:"telegram_id"`
|
||||
OTP *int64 `json:"otp"`
|
||||
InitData *string `json:"init_data"`
|
||||
}
|
||||
@@ -39,3 +39,37 @@ type FamilyThread struct {
|
||||
CreatedBy int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateFamilyRequest struct {
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
TelegramChatID int64 `json:"telegram_chat_id"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
}
|
||||
|
||||
type UpdateFamilyRequest struct {
|
||||
Name *string `json:"name"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
}
|
||||
|
||||
type FamilyResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
TelegramChatID int64 `json:"telegram_chat_id"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (response *FamilyResponse) ModelToResponse(f *Family) FamilyResponse {
|
||||
return FamilyResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
OwnerID: f.OwnerID,
|
||||
TelegramChatID: f.TelegramChatID,
|
||||
TelegramChatName: f.TelegramChatName,
|
||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: f.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type OTP struct {
|
||||
UserID int64
|
||||
Code string
|
||||
ExpiredAt time.Time
|
||||
}
|
||||
@@ -62,3 +62,14 @@ type Receipt struct {
|
||||
PositionsRaw string `json:"positions"`
|
||||
Positions []Position `json:"-"`
|
||||
}
|
||||
|
||||
type AddReceiptRequest struct {
|
||||
Number string `json:"number" binding:"required,min=24,max=24"`
|
||||
Date string `json:"date" binding:"required"`
|
||||
}
|
||||
|
||||
type AddReceiptResponse struct {
|
||||
ID int32 `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
+45
-2
@@ -4,13 +4,56 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
type UserModel struct {
|
||||
ID int64
|
||||
TelegramID int64
|
||||
Username *string
|
||||
FirstName string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
LanguageCode *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
TelegramID int64 `json:"telegram_id" validate:"required"`
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name" validate:"required"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (response *UserResponse) ModelToResponse(u *UserModel) UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
TelegramID: u.TelegramID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
LanguageCode: u.LanguageCode,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errUserNotFound = errors.New("user not found")
|
||||
|
||||
func NewApiClient(config config.Config) (*HTTPClient, error) {
|
||||
return &HTTPClient{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.config.APIHost+c.config.APIPort+"/receipts",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error {
|
||||
registered, err := c.IsUserRegistered(ctx, payload.TelegramID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registered {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.RegisterUser(ctx, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) IsUserRegistered(ctx context.Context, telegramID int64) (bool, error) {
|
||||
_, err := c.GetUserByTelegramID(ctx, telegramID)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, errUserNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error {
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.config.APIHost+c.config.APIPort+"/api/v1/users",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.config.APIHost+c.config.APIPort+"/api/v1/users/by-telegram/"+strconv.FormatInt(telegramID, 10),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, errUserNotFound
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var user domain.UserResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.config.APIHost+c.config.APIPort+"/api/v1/families",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strPtr(v string) *string {
|
||||
return &v
|
||||
}
|
||||
|
||||
func testConfig(baseURL string) config.Config {
|
||||
return config.Config{
|
||||
APIHost: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_EnsureUser_AlreadyExists(t *testing.T) {
|
||||
var postCalls int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(domain.UserResponse{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewApiClient(testConfig(ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUser returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&postCalls); got != 0 {
|
||||
t.Fatalf("expected no POST calls, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_EnsureUser_CreateOnNotFound(t *testing.T) {
|
||||
var getCalls int32
|
||||
var postCalls int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||
atomic.AddInt32(&getCalls, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
var req domain.CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("failed to decode body: %v", err)
|
||||
}
|
||||
if req.TelegramID != 100500 || req.FirstName == nil || *req.FirstName != "John" {
|
||||
t.Fatalf("unexpected payload: %+v", req)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewApiClient(testConfig(ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUser returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&getCalls); got != 1 {
|
||||
t.Fatalf("expected 1 GET call, got %d", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&postCalls); got != 1 {
|
||||
t.Fatalf("expected 1 POST call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_EnsureUser_ReturnsErrorWhenLookupFails(t *testing.T) {
|
||||
var postCalls int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewApiClient(testConfig(ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&postCalls); got != 0 {
|
||||
t.Fatalf("expected no POST calls, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewBotClient(config config.Config) (*HTTPClient, error) {
|
||||
return &HTTPClient{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ApiClient interface {
|
||||
SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error
|
||||
EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error
|
||||
IsUserRegistered(ctx context.Context, telegramID int64) (bool, error)
|
||||
RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error
|
||||
GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error)
|
||||
CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error
|
||||
}
|
||||
|
||||
type BotClient interface {
|
||||
SendMessage(ctx context.Context, chatId int64, message string) error
|
||||
}
|
||||
|
||||
type HTTPClient struct {
|
||||
config config.Config
|
||||
client *http.Client
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package receiptApi
|
||||
package familyHub
|
||||
|
||||
type ReceiptPayload struct {
|
||||
Number string `json:"number"`
|
||||
@@ -1,7 +0,0 @@
|
||||
package receiptApi
|
||||
|
||||
import "context"
|
||||
|
||||
type Client interface {
|
||||
SendReceipt(ctx context.Context, payload ReceiptPayload) error
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package receiptApi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
//apiKey string
|
||||
}
|
||||
|
||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
||||
return &HTTPClient{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) SendReceipt(
|
||||
ctx context.Context,
|
||||
payload ReceiptPayload,
|
||||
) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.baseURL+"/receipts",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type OTPRepository interface {
|
||||
Create(ctx context.Context, otp *domain.OTP) error
|
||||
Get(ctx context.Context, userID int64, code string) (*domain.OTP, error)
|
||||
}
|
||||
|
||||
type OTPSQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewOTPSQLRepository(db *sql.DB) *OTPSQLRepository {
|
||||
return &OTPSQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *OTPSQLRepository) Create(ctx context.Context, otp *domain.OTP) error {
|
||||
query := `
|
||||
INSERT INTO otp (user_id, otp, expired_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, otp.UserID, otp.Code, otp.ExpiredAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *OTPSQLRepository) Get(ctx context.Context, userID int64, code string) (*domain.OTP, error) {
|
||||
query := `
|
||||
DELETE FROM otp
|
||||
WHERE ctid IN (
|
||||
SELECT ctid
|
||||
FROM otp
|
||||
WHERE user_id = $1
|
||||
AND otp = $2
|
||||
AND expired_at > NOW()
|
||||
ORDER BY expired_at
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING user_id, otp, expired_at
|
||||
`
|
||||
|
||||
var otp domain.OTP
|
||||
err := r.db.QueryRowContext(ctx, query, userID, code).Scan(
|
||||
&otp.UserID,
|
||||
&otp.Code,
|
||||
&otp.ExpiredAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &otp, nil
|
||||
}
|
||||
+10
-10
@@ -8,10 +8,10 @@ import (
|
||||
)
|
||||
|
||||
type UsersRepository interface {
|
||||
Create(ctx context.Context, user *domain.User) error
|
||||
GetByID(ctx context.Context, id int64) (*domain.User, error)
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error)
|
||||
Update(ctx context.Context, user *domain.User) error
|
||||
Create(ctx context.Context, user *domain.UserModel) error
|
||||
GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
|
||||
Update(ctx context.Context, user *domain.UserModel) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewUsersSQLRepository(db *sql.DB) *UsersSQLRepository {
|
||||
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 := `
|
||||
INSERT INTO users
|
||||
(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,
|
||||
).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 := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -56,7 +56,7 @@ func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Use
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var user domain.User
|
||||
var user domain.UserModel
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&user.ID,
|
||||
@@ -78,7 +78,7 @@ func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Use
|
||||
|
||||
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 := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -93,7 +93,7 @@ func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int
|
||||
WHERE telegram_id = $1
|
||||
`
|
||||
|
||||
var user domain.User
|
||||
var user domain.UserModel
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, telegramID).Scan(
|
||||
&user.ID,
|
||||
@@ -115,7 +115,7 @@ func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int
|
||||
|
||||
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 := `
|
||||
UPDATE users SET
|
||||
username = $1,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTManager struct {
|
||||
secret string
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string) *JWTManager {
|
||||
return &JWTManager{secret: secret}
|
||||
}
|
||||
|
||||
func (j *JWTManager) Generate(userID int64) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(j.secret))
|
||||
}
|
||||
Reference in New Issue
Block a user