diff --git a/.gitignore b/.gitignore index b0e0904..b9e09f7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ secret_key.json data archive -volumes \ No newline at end of file +volumes +*.dtmp \ No newline at end of file diff --git a/FamilyHub.drawio b/FamilyHub.drawio index e7aa1c2..38b50c9 100644 --- a/FamilyHub.drawio +++ b/FamilyHub.drawio @@ -1,4 +1,4 @@ - + @@ -619,4 +619,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3527f57 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +hello: + echo "Hello world" \ No newline at end of file diff --git a/README.md b/README.md index e69de29..117c92d 100644 --- a/README.md +++ b/README.md @@ -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 доступна на запись. diff --git a/docker-compose.yml b/docker-compose.yml index 9f6de88..544d537 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/postgres-pg-cron/Dockerfile b/docker/postgres-pg-cron/Dockerfile new file mode 100644 index 0000000..1ebcdc4 --- /dev/null +++ b/docker/postgres-pg-cron/Dockerfile @@ -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/* diff --git a/docker/postgres-pg-cron/init/001-create-pg-cron-extension.sql b/docker/postgres-pg-cron/init/001-create-pg-cron-extension.sql new file mode 100644 index 0000000..ecf54c0 --- /dev/null +++ b/docker/postgres-pg-cron/init/001-create-pg-cron-extension.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS pg_cron; diff --git a/docs/finance_module_specification.md b/docs/finance_module_specification.md new file mode 100644 index 0000000..3027752 --- /dev/null +++ b/docs/finance_module_specification.md @@ -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? +- [ ] Нужны ли роли внутри семьи? + diff --git a/docs/process.md b/docs/process.md new file mode 100644 index 0000000..0b7d033 --- /dev/null +++ b/docs/process.md @@ -0,0 +1,29 @@ +# Бизнес процессы + +## Оглавление + +## Активация бота + +- Пользователь активирует бота и отправляет команду */start* +- Бот стартует, присылает юзеру приветственное сообщение с информацией о том что он за бот и что он + умеет +- Пользователю становятся доступны кнопки/команды */register*, */termsOfService*, *help*. +- Прочие команды игнорируются + +## Мультитенантность +### Регистрация пользователя + +- По команде */register* бот идёт в апи, проверяет зарегистрирован ли пользователь и если нет то + присылает пользователю лицензионное соглашение. +- Далее появляется кнопка */getAgreement* после нажатия которой пользователь должен самостоятельно + ввести некоторый текст, который будет являться подтверждением принятия условий. в прочих ситуациях + кнопка *getAgreement* не доступна +- После успешного принятия условий бот регистрирует пользователя в системе. +- После успешной регистрации пользователю доступны команды *createFamily*, *help*, *info* + +### Создание или присоединение к семейному аккаунту + +- По команде *createFamily* бот проверяет есть ли у этого пользователя уже созданные семейные чаты +- если нет, то предлагает создать новый чат, запрашивает имя чата, картинку на иконку чата и создаёт + супергруппу с темами +- или предлагает присоединиться к семье, запрашивает код, который может выдать владелец семьи diff --git a/go.mod b/go.mod index 2697674..11c81d2 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/migrations/000007_create_otp.down.sql b/migrations/000007_create_otp.down.sql new file mode 100644 index 0000000..76f8438 --- /dev/null +++ b/migrations/000007_create_otp.down.sql @@ -0,0 +1,3 @@ +SELECT cron.unschedule('cleanup-expired-otp'); + +DROP TABLE IF EXISTS otp; diff --git a/migrations/000007_create_otp.up.sql b/migrations/000007_create_otp.up.sql new file mode 100644 index 0000000..908c216 --- /dev/null +++ b/migrations/000007_create_otp.up.sql @@ -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()$$ +); diff --git a/src/api/dto/families.go b/src/api/dto/families.go deleted file mode 100644 index b107d6e..0000000 --- a/src/api/dto/families.go +++ /dev/null @@ -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), - } -} diff --git a/src/api/dto/receipts.go b/src/api/dto/receipts.go deleted file mode 100644 index 6e5cf27..0000000 --- a/src/api/dto/receipts.go +++ /dev/null @@ -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"` -} diff --git a/src/api/dto/users.go b/src/api/dto/users.go deleted file mode 100644 index 62ad62f..0000000 --- a/src/api/dto/users.go +++ /dev/null @@ -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), - } -} diff --git a/src/api/routers/auth.go b/src/api/routers/auth.go new file mode 100644 index 0000000..c73e020 --- /dev/null +++ b/src/api/routers/auth.go @@ -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) { + +} diff --git a/src/api/routers/families.go b/src/api/routers/families.go index 244f830..7a53a26 100644 --- a/src/api/routers/families.go +++ b/src/api/routers/families.go @@ -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 diff --git a/src/api/routers/families_test.go b/src/api/routers/families_test.go index 48f6f20..03d10a4 100644 --- a/src/api/routers/families_test.go +++ b/src/api/routers/families_test.go @@ -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) diff --git a/src/api/routers/receipts.go b/src/api/routers/receipts.go index bc416f6..616eef0 100644 --- a/src/api/routers/receipts.go +++ b/src/api/routers/receipts.go @@ -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, diff --git a/src/api/routers/users.go b/src/api/routers/users.go index 5e957d8..7752108 100644 --- a/src/api/routers/users.go +++ b/src/api/routers/users.go @@ -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"}) } } diff --git a/src/api/routers/users_test.go b/src/api/routers/users_test.go index 27fa0f1..9fa0bad 100644 --- a/src/api/routers/users_test.go +++ b/src/api/routers/users_test.go @@ -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 diff --git a/src/api/server.go b/src/api/server.go index 7ead90e..bd770bf 100644 --- a/src/api/server.go +++ b/src/api/server.go @@ -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, diff --git a/src/api/services/auth.go b/src/api/services/auth.go new file mode 100644 index 0000000..0010248 --- /dev/null +++ b/src/api/services/auth.go @@ -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 +} diff --git a/src/api/services/families.go b/src/api/services/families.go index ba95dc1..0689b50 100644 --- a/src/api/services/families.go +++ b/src/api/services/families.go @@ -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 diff --git a/src/api/services/users.go b/src/api/services/users.go index 3a2ee9e..7f32139 100644 --- a/src/api/services/users.go +++ b/src/api/services/users.go @@ -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 { diff --git a/src/bot/bot.go b/src/bot/bot.go index b73de9a..91d004c 100644 --- a/src/bot/bot.go +++ b/src/bot/bot.go @@ -1,20 +1,22 @@ 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" ) type Bot struct { - api *tgbotapi.BotAPI - ocr ocr.OCR - receiptApi receiptApi.Client + api *tgbotapi.BotAPI + ocr ocr.OCR + 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) } } } diff --git a/src/bot/handlers.go b/src/bot/handlers.go deleted file mode 100644 index 7a91e03..0000000 --- a/src/bot/handlers.go +++ /dev/null @@ -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) -} diff --git a/src/bot/handlers/create_family.go b/src/bot/handlers/create_family.go new file mode 100644 index 0000000..51697df --- /dev/null +++ b/src/bot/handlers/create_family.go @@ -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, "Семья создана успешно") +} diff --git a/src/bot/handlers/handler.go b/src/bot/handlers/handler.go new file mode 100644 index 0000000..439f9c2 --- /dev/null +++ b/src/bot/handlers/handler.go @@ -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{}, + } +} diff --git a/src/bot/handlers/help.go b/src/bot/handlers/help.go new file mode 100644 index 0000000..e90ab6f --- /dev/null +++ b/src/bot/handlers/help.go @@ -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") +} diff --git a/src/bot/handlers/photo.go b/src/bot/handlers/photo.go new file mode 100644 index 0000000..f94c435 --- /dev/null +++ b/src/bot/handlers/photo.go @@ -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) +} diff --git a/src/bot/handlers/register.go b/src/bot/handlers/register.go new file mode 100644 index 0000000..2ebff18 --- /dev/null +++ b/src/bot/handlers/register.go @@ -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) +} diff --git a/src/bot/handlers/reply.go b/src/bot/handlers/reply.go new file mode 100644 index 0000000..34c76c0 --- /dev/null +++ b/src/bot/handlers/reply.go @@ -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) +} diff --git a/src/bot/handlers/start.go b/src/bot/handlers/start.go new file mode 100644 index 0000000..765cc7a --- /dev/null +++ b/src/bot/handlers/start.go @@ -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") +} diff --git a/src/bot/handlers/state.go b/src/bot/handlers/state.go new file mode 100644 index 0000000..c5fa711 --- /dev/null +++ b/src/bot/handlers/state.go @@ -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 +} diff --git a/src/bot/handlers/unknown.go b/src/bot/handlers/unknown.go new file mode 100644 index 0000000..63af6d8 --- /dev/null +++ b/src/bot/handlers/unknown.go @@ -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) + } +} diff --git a/src/bot/handlers/utils.go b/src/bot/handlers/utils.go new file mode 100644 index 0000000..a158a3e --- /dev/null +++ b/src/bot/handlers/utils.go @@ -0,0 +1,9 @@ +package handlers + +func stringPtrOrNil(value string) *string { + if value == "" { + return nil + } + + return &value +} diff --git a/src/bot/routers.go b/src/bot/routers.go new file mode 100644 index 0000000..0b07fe4 --- /dev/null +++ b/src/bot/routers.go @@ -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) + } +} diff --git a/src/config/config.go b/src/config/config.go index 1d2f13c..5297687 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -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 } diff --git a/src/domain/auth.go b/src/domain/auth.go new file mode 100644 index 0000000..c8ab7f8 --- /dev/null +++ b/src/domain/auth.go @@ -0,0 +1,7 @@ +package domain + +type AuthRequest struct { + TelegramId *string `json:"telegram_id"` + OTP *int64 `json:"otp"` + InitData *string `json:"init_data"` +} diff --git a/src/domain/families.go b/src/domain/families.go index 3623ab5..82e3a2a 100644 --- a/src/domain/families.go +++ b/src/domain/families.go @@ -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), + } +} diff --git a/src/domain/otp.go b/src/domain/otp.go new file mode 100644 index 0000000..0e7fcd6 --- /dev/null +++ b/src/domain/otp.go @@ -0,0 +1,9 @@ +package domain + +import "time" + +type OTP struct { + UserID int64 + Code string + ExpiredAt time.Time +} diff --git a/src/domain/receipt.go b/src/domain/receipt.go index 0231919..7739038 100644 --- a/src/domain/receipt.go +++ b/src/domain/receipt.go @@ -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"` +} diff --git a/src/domain/users.go b/src/domain/users.go index ae9eb12..5062586 100644 --- a/src/domain/users.go +++ b/src/domain/users.go @@ -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), + } +} diff --git a/src/integrations/familyHub/apiClient.go b/src/integrations/familyHub/apiClient.go new file mode 100644 index 0000000..efe3554 --- /dev/null +++ b/src/integrations/familyHub/apiClient.go @@ -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 +} diff --git a/src/integrations/familyHub/apiClient_test.go b/src/integrations/familyHub/apiClient_test.go new file mode 100644 index 0000000..349b75d --- /dev/null +++ b/src/integrations/familyHub/apiClient_test.go @@ -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) + } +} diff --git a/src/integrations/familyHub/botClient.go b/src/integrations/familyHub/botClient.go new file mode 100644 index 0000000..335459b --- /dev/null +++ b/src/integrations/familyHub/botClient.go @@ -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 +} diff --git a/src/integrations/familyHub/client.go b/src/integrations/familyHub/client.go new file mode 100644 index 0000000..aec3ff6 --- /dev/null +++ b/src/integrations/familyHub/client.go @@ -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 +} diff --git a/src/integrations/receiptApi/dto.go b/src/integrations/familyHub/dto.go similarity index 82% rename from src/integrations/receiptApi/dto.go rename to src/integrations/familyHub/dto.go index ccd38a3..b11df89 100644 --- a/src/integrations/receiptApi/dto.go +++ b/src/integrations/familyHub/dto.go @@ -1,4 +1,4 @@ -package receiptApi +package familyHub type ReceiptPayload struct { Number string `json:"number"` diff --git a/src/integrations/receiptApi/client.go b/src/integrations/receiptApi/client.go deleted file mode 100644 index 2b6443e..0000000 --- a/src/integrations/receiptApi/client.go +++ /dev/null @@ -1,7 +0,0 @@ -package receiptApi - -import "context" - -type Client interface { - SendReceipt(ctx context.Context, payload ReceiptPayload) error -} diff --git a/src/integrations/receiptApi/http_client.go b/src/integrations/receiptApi/http_client.go deleted file mode 100644 index 89745fa..0000000 --- a/src/integrations/receiptApi/http_client.go +++ /dev/null @@ -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 -} diff --git a/src/repositories/otp.go b/src/repositories/otp.go new file mode 100644 index 0000000..be78735 --- /dev/null +++ b/src/repositories/otp.go @@ -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 +} diff --git a/src/repositories/users.go b/src/repositories/users.go index a21a1af..b903a6b 100644 --- a/src/repositories/users.go +++ b/src/repositories/users.go @@ -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, diff --git a/src/utils/jwt.go b/src/utils/jwt.go new file mode 100644 index 0000000..c3c2be4 --- /dev/null +++ b/src/utils/jwt.go @@ -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)) +}