docs: update README, env example, and design docs
- backend/.env.example: add GEMINI_API_KEY and PEXELS_API_KEY placeholders - backend/Makefile: add test-integration to PHONY targets - backend/README.md: document external API keys, import/translate commands - docs/Design.md, docs/Tech.md: reflect Iteration 1 implementation and future plans Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,3 +12,7 @@ JWT_REFRESH_DURATION=720h
|
||||
# Server
|
||||
PORT=8080
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# External APIs
|
||||
GEMINI_API_KEY=your-gemini-key
|
||||
PEXELS_API_KEY=your-pexels-key
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: run test lint migrate-up migrate-down migrate-create migrate-status docker-up docker-down docker-logs
|
||||
.PHONY: run test test-integration lint migrate-up migrate-down migrate-create migrate-status docker-up docker-down docker-logs
|
||||
|
||||
ifneq (,$(wildcard .env))
|
||||
include .env
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FoodAI Backend
|
||||
|
||||
Go REST API с авторизацией через Firebase, JWT и PostgreSQL.
|
||||
Go REST API с авторизацией через Firebase, JWT и PostgreSQL. Включает инфраструктуру импорта и перевода справочных данных (ингредиенты, рецепты).
|
||||
|
||||
## Стек
|
||||
|
||||
@@ -10,6 +10,8 @@ Go REST API с авторизацией через Firebase, JWT и PostgreSQL.
|
||||
- **goose** — миграции
|
||||
- **golang-jwt/v5** — JWT
|
||||
- **Firebase Admin SDK** — верификация токенов
|
||||
- **cobra** — CLI для команд импорта
|
||||
- **generative-ai-go** — Gemini API (переводы)
|
||||
|
||||
## Требования
|
||||
|
||||
@@ -37,6 +39,8 @@ cp .env.example .env
|
||||
| `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` |
|
||||
| `PORT` | Порт сервера | `8080` |
|
||||
| `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` |
|
||||
| `SPOONACULAR_API_KEY` | Ключ Spoonacular API (нужен для команд `import`) | — |
|
||||
| `GEMINI_API_KEY` | Ключ Gemini API (нужен для команд `translate`) | — |
|
||||
|
||||
### 2. Запуск через Docker Compose
|
||||
|
||||
@@ -61,12 +65,14 @@ make run
|
||||
|
||||
## Команды
|
||||
|
||||
### Сервер и тесты
|
||||
|
||||
| Команда | Описание |
|
||||
|---|---|
|
||||
| `make run` | Запустить сервер в режиме разработки |
|
||||
| `make test` | Запустить unit-тесты |
|
||||
| `make test-integration` | Запустить интеграционные тесты (требует Docker) |
|
||||
| `make lint` | Проверить код через golangci-lint |
|
||||
| `make test` | Unit-тесты |
|
||||
| `make test-integration` | Интеграционные тесты (требует Docker) |
|
||||
| `make lint` | Проверка через golangci-lint |
|
||||
| `make docker-up` | Поднять PostgreSQL + приложение |
|
||||
| `make docker-down` | Остановить контейнеры |
|
||||
| `make docker-logs` | Логи приложения |
|
||||
@@ -75,6 +81,40 @@ make run
|
||||
| `make migrate-status` | Статус миграций |
|
||||
| `make migrate-create name=<name>` | Создать новую миграцию |
|
||||
|
||||
### Импорт данных (требует `SPOONACULAR_API_KEY`)
|
||||
|
||||
| Команда | Описание |
|
||||
|---|---|
|
||||
| `make import-ingredients` | Импортировать ~1 000 ингредиентов |
|
||||
| `make import-recipes` | Импортировать ~5 000 рецептов |
|
||||
| `make import-recipes-full` | Импортировать ~10 000 рецептов |
|
||||
|
||||
### Перевод (требует `GEMINI_API_KEY`)
|
||||
|
||||
| Команда | Описание |
|
||||
|---|---|
|
||||
| `make translate-recipes` | Перевести рецепты на русский |
|
||||
| `make translate-ingredients` | Перевести топ-200 ингредиентов |
|
||||
| `make import-all` | Полный пайплайн: ингредиенты → рецепты → переводы |
|
||||
|
||||
Все команды импорта идемпотентны (`ON CONFLICT DO UPDATE`) — можно запускать повторно. Для возобновления прерванного импорта используйте флаги `--skip-queries` / `--offset`.
|
||||
|
||||
### CLI напрямую
|
||||
|
||||
```bash
|
||||
# Тестовый прогон (без сохранения в БД)
|
||||
go run ./cmd/import import ingredients --limit 50 --dry-run
|
||||
go run ./cmd/import import recipes --count 100 --dry-run
|
||||
|
||||
# Возобновление импорта
|
||||
go run ./cmd/import import ingredients --limit 1000 --skip-queries 10
|
||||
go run ./cmd/import import recipes --count 5000 --offset 2000
|
||||
|
||||
# Перевод части рецептов
|
||||
go run ./cmd/import translate recipes --limit 1000
|
||||
go run ./cmd/import translate ingredients --top 50
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Публичные эндпоинты
|
||||
@@ -120,18 +160,85 @@ curl -X PUT http://localhost:8080/profile \
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/server/ # Точка входа
|
||||
├── cmd/
|
||||
│ ├── server/ # HTTP-сервер (точка входа)
|
||||
│ └── import/ # CLI для импорта и перевода данных (Cobra)
|
||||
├── internal/
|
||||
│ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации
|
||||
│ ├── config/ # Конфигурация через переменные окружения
|
||||
│ ├── database/ # Подключение к PostgreSQL (pgxpool)
|
||||
│ ├── ingredient/ # Модель, репозиторий, сервис импорта ингредиентов
|
||||
│ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth
|
||||
│ ├── recipe/ # Модель, репозиторий, сервис импорта рецептов
|
||||
│ ├── server/ # Роутер (chi)
|
||||
│ ├── spoonacular/ # HTTP-клиент Spoonacular API (интерфейс + реализация)
|
||||
│ ├── testutil/ # Вспомогательные утилиты для тестов
|
||||
│ ├── translation/ # Gemini-переводчик, сервис батчевого перевода
|
||||
│ └── user/ # Модель, репозиторий, сервис, хэндлер, расчёт калорий
|
||||
├── migrations/ # SQL-миграции (goose)
|
||||
├── migrations/
|
||||
│ ├── 001_create_users.sql
|
||||
│ ├── 002_create_ingredient_mappings.sql # GIN-индекс по aliases
|
||||
│ └── 003_create_recipes.sql # FTS + GIN по ingredients/tags
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Схема БД
|
||||
|
||||
### `ingredient_mappings`
|
||||
|
||||
Канонический справочник ингредиентов. Каждая запись — один вид продукта.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | UUID | Первичный ключ |
|
||||
| `canonical_name` | varchar | Нормализованное EN-название (`chicken_breast`) |
|
||||
| `canonical_name_ru` | varchar | Русское название (`куриная грудка`) |
|
||||
| `spoonacular_id` | integer | Уникальный ID из Spoonacular |
|
||||
| `aliases` | JSONB | Массив альтернативных названий (EN + RU) |
|
||||
| `category` | varchar | `produce`, `dairy`, `meat`, `seafood`, `grains`, `spices`, `canned`, `frozen`, `beverages`, `other` |
|
||||
| `default_unit` | varchar | Единица измерения по умолчанию (`g`, `ml`) |
|
||||
| `calories_per_100g` | decimal | Нутриенты на 100 г |
|
||||
| `storage_days` | integer | Типичный срок хранения (дни) |
|
||||
|
||||
Индексы: GIN по `aliases` (поиск `@>`), `canonical_name`, `category`, UNIQUE по `spoonacular_id`.
|
||||
|
||||
### `recipes`
|
||||
|
||||
Каталог рецептов. Заполняется из Spoonacular, переводится через Gemini.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | UUID | Первичный ключ |
|
||||
| `source` | enum | `spoonacular`, `ai`, `user` |
|
||||
| `spoonacular_id` | integer | Уникальный ID из Spoonacular |
|
||||
| `title` / `title_ru` | varchar | Название EN/RU |
|
||||
| `difficulty` | enum | `easy` (≤30 мин), `medium` (≤60 мин), `hard` |
|
||||
| `ingredients` | JSONB | Массив `{spoonacular_id, mapping_id, name, amount, unit}` |
|
||||
| `steps` | JSONB | Массив `{number, description, description_ru, timer_seconds}` |
|
||||
| `tags` | JSONB | `["vegetarian", "gluten-free", "meal:dinner", ...]` |
|
||||
| `calories_per_serving` | decimal | Нутриенты на порцию |
|
||||
| `avg_rating` / `review_count` | decimal/int | Рейтинг (обновляется при отзывах) |
|
||||
|
||||
Индексы: GIN по `ingredients` (поиск по `mapping_id`), GIN по `tags`, FTS по `title + title_ru`.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
# Unit-тесты (~69 тестов, ~13 сек)
|
||||
make test
|
||||
|
||||
# Интеграционные тесты (PostgreSQL в Docker через testcontainers)
|
||||
make test-integration
|
||||
```
|
||||
|
||||
| Пакет | Unit | Integration |
|
||||
|---|---|---|
|
||||
| `auth` | 17 | 10 |
|
||||
| `ingredient` | 9 | 5 |
|
||||
| `middleware` | 10 | — |
|
||||
| `recipe` | 12 | 7 |
|
||||
| `translation` | 6 | — |
|
||||
| `user` | 14 | 12 |
|
||||
|
||||
119
docs/Design.md
119
docs/Design.md
@@ -953,98 +953,93 @@
|
||||
|
||||
---
|
||||
|
||||
## 9. Каталог рецептов
|
||||
## 9. AI-рекомендации рецептов
|
||||
|
||||
Поиск и просмотр рецептов с фильтрацией и персональными рекомендациями.
|
||||
Персонализированные рецепты, сгенерированные Gemini на основе продуктов пользователя, цели и предпочтений. Статического каталога нет — каждый запрос даёт новую подборку.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Рецепты │
|
||||
│ Рецепты [🔄] │
|
||||
├─────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔍 Найти рецепт... │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Из моих продуктов] [Фильтры ▾] │
|
||||
│ Для вас сегодня │
|
||||
│ На основе ваших продуктов │
|
||||
│ │
|
||||
│ Для вас │
|
||||
│ ┌──────────┐ ┌──────────┐ → │
|
||||
│ │ [фото] │ │ [фото] │ │
|
||||
│ │ Том Ям │ │ Пад Тай │ │
|
||||
│ │ ★4.8 │ │ ★4.6 │ │
|
||||
│ │ 320 ккал │ │ 450 ккал │ │
|
||||
│ │ Есть всё✓│ │ -2 прод. │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [фото блюда] │ │
|
||||
│ │ │ │
|
||||
│ │ Куриная грудка с овощами │ │
|
||||
│ │ ≈ 420 ккал · 35 мин · Лёгко│ │
|
||||
│ │ Б: 48г Ж: 12г У: 18г │ │
|
||||
│ │ [♡] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Готовили недавно │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [фото блюда] │ │
|
||||
│ │ │ │
|
||||
│ │ Рисовый суп с яйцом │ │
|
||||
│ │ ≈ 310 ккал · 20 мин · Лёгко│ │
|
||||
│ │ Б: 18г Ж: 8г У: 42г │ │
|
||||
│ │ [♡] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [фото блюда] │ │
|
||||
│ │ │ │
|
||||
│ │ Морковный суп-пюре │ │
|
||||
│ │ ≈ 180 ккал · 25 мин · Лёгко│ │
|
||||
│ │ Б: 4г Ж: 7г У: 26г │ │
|
||||
│ │ [♡] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ + ещё 2 рецепта │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Сохранённые рецепты │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │[фото] │ │[фото] │ │[фото] │ │
|
||||
│ │Карбон. │ │Борщ │ │Цезарь │ │
|
||||
│ │♡ saved │ │♡ saved │ │♡ saved │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Все рецепты │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ [фото] │ │ [фото] │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Ризотто │ │ Борщ │ │
|
||||
│ │ ★4.5 │ │ ★4.9 │ │
|
||||
│ │ 480 ккал │ │ 350 ккал │ │
|
||||
│ │ 50 мин │ │ 90 мин │ │
|
||||
│ │ Сложная │ │ Средняя │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ ... │ │ ... │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ [Все сохранённые →] │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Панель фильтров (раскрывается по тапу «Фильтры ▾»)
|
||||
### Скелетон при загрузке (2–4 сек)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Фильтры [Сброс]│
|
||||
│ Рецепты │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Приём пищи │
|
||||
│ [Завтрак] [Обед] [Ужин] [Перекус] │
|
||||
│ Подбираем рецепты... │
|
||||
│ │
|
||||
│ Кухня │
|
||||
│ [Русская] [Азиатская] [Европейская] │
|
||||
│ [Средиземноморская] [Американская] │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
|
||||
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
|
||||
│ │ ░░░░░░░░ ░░░░░ ░░░░░░░░ │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
|
||||
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
|
||||
│ │ ░░░░░░░░ ░░░░░ ░░░░░░░░ │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Сложность │
|
||||
│ [Простая] [Средняя] [Сложная] │
|
||||
│ │
|
||||
│ Время приготовления │
|
||||
│ [до 15 мин] [до 30 мин] [до 60 мин]│
|
||||
│ [более 60 мин] │
|
||||
│ │
|
||||
│ Калорийность (на порцию) │
|
||||
│ ○────────────● до 500 ккал │
|
||||
│ │
|
||||
│ Диета │
|
||||
│ [Вегетар.] [Безглютен.] [Низкокал.] │
|
||||
│ [Кето] [Без лактозы] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Показать 24 рецепта │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Элементы и поведение
|
||||
|
||||
- **Строка поиска:** текстовый поиск по названию рецепта и ингредиентам.
|
||||
- **Кнопка «Из моих продуктов»:** toggle-фильтр. При включении показывает только рецепты, которые можно приготовить из имеющихся продуктов (полностью или частично). Рецепты сортируются по доле имеющихся ингредиентов. На каждой карточке — пометка: «Есть всё ✓» или «-N прод.».
|
||||
- **Кнопка «Фильтры»:** раскрывает панель фильтров (bottom sheet). Фильтры — chip-кнопки с множественным выбором. Слайдер для калорийности. Кнопка «Показать N рецептов» — применяет фильтры и закрывает панель. Кнопка «Сброс» — очищает все фильтры.
|
||||
- **Секция «Для вас»:** горизонтальная карусель. Персональные рекомендации на основе: предпочтений кухонь, истории оценок, имеющихся продуктов (особенно с истекающим сроком). Алгоритм: продукты с истекающим сроком > полное совпадение ингредиентов > предпочтения кухни > высокий рейтинг.
|
||||
- **Секция «Готовили недавно»:** горизонтальный ряд из последних 5 приготовленных рецептов. Быстрый доступ для повтора. Не показывается, если нет истории.
|
||||
- **Секция «Все рецепты»:** сетка 2 колонки. Каждая карточка — фото, название, рейтинг (звёзды), калорийность, время, сложность. Тап — переход в карточку рецепта.
|
||||
- **Бесконечный скролл:** подгрузка рецептов по мере прокрутки.
|
||||
- **Кнопка [🔄] «Обновить»:** принудительная перегенерация рекомендаций. Gemini + Pexels вызываются заново. Skeleton показывается на время генерации (2–4 сек).
|
||||
- **Карточка рецепта:** фото (Pexels), название, приблизительное КБЖУ (помечено «≈»), время приготовления, сложность. Тап → карточка рецепта.
|
||||
- **Кнопка [♡]:** сохранить рецепт. Рецепт записывается в `saved_recipes`. Иконка заполняется, haptic feedback.
|
||||
- **Секция «Сохранённые рецепты»:** горизонтальный ряд последних сохранённых. Кнопка «Все сохранённые →» переходит на экран всех сохранённых. Не показывается, если нет сохранённых.
|
||||
- **Автоматическая генерация:** при открытии вкладки (если с последней генерации прошло > 30 мин или изменились продукты).
|
||||
- **КБЖУ «≈»:** пиктограмма «~» перед числами; тап → tooltip «Приблизительно, рассчитано AI».
|
||||
|
||||
---
|
||||
|
||||
|
||||
243
docs/Tech.md
243
docs/Tech.md
@@ -8,10 +8,9 @@
|
||||
| База данных | PostgreSQL | Надёжная реляционная БД, JSONB для гибких структур (нутриенты, шаги рецептов), полнотекстовый поиск, зрелая экосистема |
|
||||
| Клиент | Flutter (Android, iOS, Web) | Единая кодовая база на три платформы, нативная производительность на мобильных, зрелая экосистема виджетов |
|
||||
| Авторизация | Firebase Auth | Бесплатно до 50K MAU, email + Google + Apple из коробки, официальный Go SDK, отличная интеграция с Flutter |
|
||||
| AI (vision) | Google Gemini 2.5 Flash | Лучшее соотношение цена/качество для распознавания еды (~$0.15/1M input tokens), бесплатный tier для разработки, прецедент CalCam |
|
||||
| AI (текст) | Google Gemini 2.5 Flash-Lite | Самый дешёвый вариант для текстовых задач ($0.10/1M input tokens) — генерация рецептов, меню, замены ингредиентов |
|
||||
| База рецептов | Spoonacular API | 365K+ рецептов с нутриентами, фото, ингредиентами, шагами. $29/мес на старте |
|
||||
| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, фото рецептов |
|
||||
| AI (vision + текст) | Google Gemini 2.5 Flash | Единая модель для распознавания фото (чеки, продукты, блюда), генерации рецептов, меню и замены ингредиентов. Free tier для разработки |
|
||||
| Изображения | Pexels API | Фото к рецептам. Бесплатно до 20K req/мес, коммерческое использование без атрибуции |
|
||||
| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, загружаемые для распознавания |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,8 +68,8 @@
|
||||
│ - meal_diary │ ▼
|
||||
│ - reviews │ ┌──────────────────┐
|
||||
│ - ai_tasks │ │ │
|
||||
│ │ │ Spoonacular │
|
||||
└──────────────────┘ │ API │
|
||||
│ │ │ Pexels API │
|
||||
└──────────────────┘ │ (фото к рец.) │
|
||||
│ │
|
||||
└──────────────────┘
|
||||
```
|
||||
@@ -218,35 +217,37 @@ AI-провайдер скрыт за интерфейсами. Это позв
|
||||
#### 4.4. Генерация рецептов / подбор меню
|
||||
|
||||
- **Модель:** Gemini 2.5 Flash-Lite (текст) — дешевле, vision не нужен
|
||||
- **Ключевой принцип:** AI НЕ генерирует рецепты с нуля. Вместо этого:
|
||||
- **Ключевой принцип:** Gemini генерирует рецепты с нуля на основе контекста пользователя. Статическая база рецептов не нужна.
|
||||
|
||||
```
|
||||
1. Backend выбирает из БД кандидатов-рецептов (по фильтрам: кухня, сложность, время, ингредиенты)
|
||||
2. AI получает список кандидатов (ID + название + ингредиенты + калории) + контекст юзера
|
||||
3. AI ранжирует, комбинирует в меню, предлагает замены
|
||||
4. Backend возвращает полные рецепты по ID из БД
|
||||
1. Backend собирает контекст: продукты пользователя (с учётом сроков хранения),
|
||||
профиль (цель, КБЖУ), предпочтения кухни, диетические ограничения
|
||||
2. Gemini генерирует полные рецепты: название, ингредиенты с граммовками,
|
||||
шаги, КБЖУ на порцию, image_query для Pexels
|
||||
3. Backend параллельно запрашивает фото из Pexels по image_query
|
||||
4. Результат возвращается клиенту; при сохранении → пишется в saved_recipes
|
||||
```
|
||||
|
||||
- **Промпт для подбора меню:**
|
||||
- **Промпт для рекомендаций:**
|
||||
|
||||
```
|
||||
Контекст пользователя:
|
||||
- Цель: 2100 ккал/день
|
||||
Ты — диетолог-повар. Предложи 5 рецептов на русском языке.
|
||||
|
||||
Профиль:
|
||||
- Цель: похудение, 1800 ккал/день
|
||||
- Ограничения: без орехов
|
||||
- Предпочтения: русская, азиатская кухня
|
||||
- Продукты в наличии: [список с количествами и сроками]
|
||||
|
||||
Доступные рецепты (ID, название, калории, основные ингредиенты):
|
||||
[список из 50–100 кандидатов из БД]
|
||||
Доступные продукты (приоритет — скоро истекают):
|
||||
- Куриное филе 500г (истекает завтра ⚠)
|
||||
- Морковь 3 шт · Рис 400г · Яйца 4 шт
|
||||
|
||||
Задача: составь меню на 7 дней (завтрак, обед, ужин, перекус).
|
||||
Приоритет: использовать продукты с истекающим сроком.
|
||||
Ответ: JSON с recipe_id для каждого слота.
|
||||
Верни ТОЛЬКО валидный JSON без markdown. Для каждого рецепта:
|
||||
title, description, cuisine, difficulty, prep_time_min, cook_time_min,
|
||||
servings, image_query (EN), ingredients, steps, tags, nutrition_per_serving.
|
||||
```
|
||||
|
||||
- **Выход:** массив `{ day, meal_type, recipe_id }` — бэкенд подтягивает полные данные рецептов из БД.
|
||||
|
||||
Это гарантирует согласованность: AI не придумывает рецепты, а выбирает из проверенной базы.
|
||||
- **Выход:** массив рецептов с `image_query` — бэкенд дозапрашивает Pexels и возвращает готовый объект с `image_url`.
|
||||
|
||||
#### 4.5. AI-генерация персональных рецептов
|
||||
|
||||
@@ -255,7 +256,7 @@ AI-провайдер скрыт за интерфейсами. Это позв
|
||||
- **Модель:** Gemini 2.5 Flash-Lite
|
||||
- **Промпт:** «Из продуктов [список] предложи рецепт. Формат: JSON с названием, ингредиентами (с граммовками), шагами, калорийностью, БЖУ.»
|
||||
- **Результат:** сохраняется в БД как `source = 'ai_generated'`, помечается в UI как «AI-рецепт».
|
||||
- **Валидация нутриентов:** бэкенд пересчитывает калории/БЖУ по справочнику (USDA/Spoonacular nutrition data) и корректирует, если AI ошибся более чем на 20%.
|
||||
- **Валидация нутриентов:** значения КБЖУ от Gemini считаются приблизительными и помечаются «≈» в UI. Точная верификация через USDA FoodData Central запланирована в будущем (см. TODO.md).
|
||||
|
||||
#### 4.6. Замена ингредиентов
|
||||
|
||||
@@ -266,15 +267,15 @@ AI-провайдер скрыт за интерфейсами. Это позв
|
||||
|
||||
---
|
||||
|
||||
## 5. Маппинг ингредиентов (связь AI ↔ БД рецептов)
|
||||
## 5. Маппинг ингредиентов (нормализация продуктов пользователя)
|
||||
|
||||
### Проблема
|
||||
|
||||
Gemini оперирует свободным текстом ("куриное филе", "помидоры черри"), а Spoonacular — структурированными ID ингредиентов. Продукты пользователя — тоже свободный текст. Нужен слой, который связывает все три мира.
|
||||
Пользователи вводят продукты свободным текстом: "куриное филе", "куриная грудка", "курица без кости". Это одно и то же. Нужен слой нормализации — чтобы при генерации рекомендаций Gemini понимал, что у пользователя есть, и чтобы не дублировались продукты.
|
||||
|
||||
### Решение: таблица `ingredient_mappings`
|
||||
|
||||
Каноническая таблица ингредиентов с алиасами на разных языках и привязкой к Spoonacular ID.
|
||||
Каноническая таблица с алиасами на русском и английском. Заполняется по мере работы приложения через Gemini (рантайм-дополнение).
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
@@ -282,16 +283,14 @@ Gemini оперирует свободным текстом ("куриное ф
|
||||
│───────────────────────────────────────────────────────│
|
||||
│ id UUID │
|
||||
│ canonical_name "chicken_breast" │
|
||||
│ spoonacular_id 1015062 │
|
||||
│ canonical_name_ru "куриная грудка" │
|
||||
│ aliases (JSONB) ["куриное филе", "куриная грудка", │
|
||||
│ "филе курицы", "chicken breast", │
|
||||
│ "chicken fillet"] │
|
||||
│ "грудка курицы", "chicken breast"]│
|
||||
│ category "meat" │
|
||||
│ default_unit "g" │
|
||||
│ calories_per_100g 165 │
|
||||
│ calories_per_100g 165 (≈ от Gemini) │
|
||||
│ protein_per_100g 31 │
|
||||
│ fat_per_100g 3.6 │
|
||||
│ carbs_per_100g 0 │
|
||||
│ storage_days 3 │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -299,18 +298,15 @@ Gemini оперирует свободным текстом ("куриное ф
|
||||
### Как работает связка
|
||||
|
||||
```
|
||||
Продукт юзера ingredient_mappings Рецепт из Spoonacular
|
||||
────────────── ─────────────────── ─────────────────────
|
||||
Продукт юзера ingredient_mappings Промпт для Gemini
|
||||
────────────── ─────────────────── ──────────────────
|
||||
|
||||
"Куриное филе" │ Ингредиент рецепта:
|
||||
│ │ spoonacular_id: 1015062
|
||||
"Куриное филе" │ canonical_name_ru:
|
||||
│ │ "куриная грудка"
|
||||
│ Fuzzy search │ │
|
||||
└────по aliases ───► canonical_name: ◄─── по spoonacular_id
|
||||
"chicken_breast"
|
||||
│
|
||||
MATCH: оба
|
||||
ссылаются на
|
||||
одну сущность
|
||||
└────по aliases ────► canonical_name ──────────►│
|
||||
"chicken_breast" Gemini понимает
|
||||
что это за продукт
|
||||
```
|
||||
|
||||
### Потоки по сценариям
|
||||
@@ -335,32 +331,32 @@ Gemini оперирует свободным текстом ("куриное ф
|
||||
#### Сценарий 2: Проверка "есть ли ингредиент рецепта в запасах"
|
||||
|
||||
```
|
||||
1. Рецепт "Паста Карбонара" имеет ингредиент: spoonacular_id = 1015062
|
||||
2. ingredient_mappings: spoonacular_id 1015062 → canonical_name "chicken_breast"
|
||||
3. products: mapping_id → ingredient_mappings.id WHERE canonical_name = "chicken_breast"
|
||||
4. Найдено в products → отметка ✅ (есть в запасах)
|
||||
Не найдено → отметка ❌ (нет) или 🔄 (есть замена)
|
||||
1. Рецепт "Паста Карбонара" содержит ингредиент: canonical_name = "spaghetti"
|
||||
2. products: ищем WHERE mapping_id IN (
|
||||
SELECT id FROM ingredient_mappings WHERE canonical_name = 'spaghetti'
|
||||
)
|
||||
3. Найдено в products → отметка ✅ (есть в запасах)
|
||||
Не найдено → отметка ❌ (нет) или 🔄 (есть замена через ingredient_substitutions)
|
||||
```
|
||||
|
||||
#### Сценарий 3: Поиск рецептов "из моих продуктов"
|
||||
#### Сценарий 3: Рекомендации рецептов "из моих продуктов"
|
||||
|
||||
```
|
||||
1. Продукты юзера → через mapping_id → набор canonical_names
|
||||
["chicken_breast", "rice_white", "carrot", "onion"]
|
||||
1. Продукты юзера → через mapping_id → набор canonical_names_ru
|
||||
["куриная грудка", "рис", "морковь", "лук"]
|
||||
|
||||
2. SQL-запрос по рецептам:
|
||||
SELECT r.*,
|
||||
(SELECT count(*) FROM jsonb_array_elements(r.ingredients) i
|
||||
WHERE i->>'mapping_id' IN (выбранные mapping_id)) as matched,
|
||||
jsonb_array_length(r.ingredients) as total
|
||||
FROM recipes r
|
||||
ORDER BY matched::float / total DESC
|
||||
2. Backend формирует промпт для Gemini:
|
||||
- профиль пользователя (цель, КБЖУ-норма, ограничения)
|
||||
- список продуктов с количеством и сроками
|
||||
- приоритет: продукты, истекающие скоро
|
||||
|
||||
3. Если в локальной БД мало результатов — дозапрос Spoonacular:
|
||||
GET /recipes/findByIngredients?ingredients=chicken,rice,carrot,onion
|
||||
Новые рецепты сохраняются в нашу БД
|
||||
3. Gemini генерирует 5 рецептов с нуля, используя имеющиеся продукты.
|
||||
Каждый рецепт содержит image_query (EN) для запроса Pexels.
|
||||
|
||||
4. Gemini НЕ участвует — это чистый SQL + Spoonacular
|
||||
4. Backend параллельно запрашивает Pexels по image_query.
|
||||
|
||||
5. Результат: персонализированные рецепты с фото.
|
||||
Статическая база рецептов не нужна.
|
||||
```
|
||||
|
||||
#### Сценарий 4: Распознавание блюда (фото → калории)
|
||||
@@ -378,24 +374,30 @@ Gemini оперирует свободным текстом ("куриное ф
|
||||
#### Сценарий 5: Генерация меню
|
||||
|
||||
```
|
||||
1. Backend отбирает кандидатов из БД (SQL: фильтры + наличие ингредиентов)
|
||||
2. Формирует промпт с recipe_id:
|
||||
"ID:42 Борщ (550ккал, ингр: свёкла✅ картофель✅ говядина✅ лук✅)
|
||||
ID:87 Том Ям (320ккал, ингр: креветки❌ лемонграсс❌ кокос.молоко❌)"
|
||||
3. Gemini ранжирует и возвращает recipe_id
|
||||
4. Backend подгружает полные данные по ID
|
||||
1. Backend собирает контекст:
|
||||
- профиль (цель, КБЖУ-норма, кухни, ограничения)
|
||||
- продукты пользователя (canonical_names, количество, сроки)
|
||||
- период меню (например, 7 дней × 3 приёма пищи)
|
||||
|
||||
→ Gemini работает ТОЛЬКО с ID из нашей БД
|
||||
→ Никакой рассинхронизации
|
||||
2. Gemini генерирует полное меню с нуля:
|
||||
- 7 дней, 3 приёма пищи в день
|
||||
- каждый слот: полный рецепт (ингредиенты, шаги, КБЖУ, image_query)
|
||||
- баланс КБЖУ в рамках дневной нормы
|
||||
- приоритет продуктам с истекающим сроком
|
||||
|
||||
3. Backend параллельно запрашивает Pexels для каждого рецепта.
|
||||
|
||||
4. Меню сохраняется в menu_plans + menu_items.
|
||||
Рецепты из меню — в saved_recipes (source = 'ai').
|
||||
|
||||
→ Gemini генерирует контент полностью. Никакой статической базы не нужно.
|
||||
```
|
||||
|
||||
### Наполнение таблицы маппингов
|
||||
|
||||
| Этап | Источник | Объём |
|
||||
|------|----------|-------|
|
||||
| Начальный импорт | Spoonacular Ingredient API | ~1 000 базовых ингредиентов |
|
||||
| Ручная локализация | Перевод топ-200 ингредиентов | Aliases на русском |
|
||||
| Batch-перевод | Gemini Flash-Lite (оффлайн) | Остальные aliases |
|
||||
| Начальный seed | Ручное составление топ-200 базовых ингредиентов | Aliases на русском и английском |
|
||||
| Рантайм-дополнение | Gemini (при нераспознанном продукте) | По мере роста юзеров |
|
||||
| Пользовательская обратная связь | Юзер корректирует продукт на экране редактирования | Новые aliases |
|
||||
|
||||
@@ -407,9 +409,9 @@ Gemini оперирует свободным текстом ("куриное ф
|
||||
|--------|---------|---------------------|
|
||||
| OCR чека → продукты | Да (vision) | Fuzzy match по `ingredient_mappings.aliases` |
|
||||
| Фото продуктов → список | Да (vision) | То же |
|
||||
| Фото блюда → калории | Да (vision) | Full-text search по `recipes.title` |
|
||||
| Поиск рецептов из продуктов | **Нет** | SQL по `mapping_id` + Spoonacular `findByIngredients` |
|
||||
| Ранжировка/подбор меню | Да (текст) | Получает `recipe_id` из БД, возвращает `recipe_id` |
|
||||
| Фото блюда → калории | Да (vision) | Результат логируется в `ai_tasks` |
|
||||
| Рекомендации рецептов из продуктов | **Да (текст)** | Продукты передаются в промпт; результат → `saved_recipes` |
|
||||
| Генерация меню на неделю | Да (текст) | Генерирует с нуля; сохраняется в `menu_plans` + `menu_items` |
|
||||
| Замена ингредиентов | Да (текст) | Кеш в `ingredient_substitutions` |
|
||||
| Нераспознанный ингредиент | Да (текст, разово) | Результат сохраняется в `ingredient_mappings` |
|
||||
|
||||
@@ -520,50 +522,42 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
|
||||
### Источники рецептов
|
||||
|
||||
Статической базы рецептов нет. Все рецепты генерируются Gemini on-demand и сохраняются пользователями.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Recipe Service │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │ Spoonacular │ │ AI-generated │ │ User │ │
|
||||
│ │ Import │ │ Recipes │ │ Recipes │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ source: │ │ source: │ │ source: │ │
|
||||
│ │ 'spoonacular'│ │ 'ai' │ │ 'user' │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────┬────────┘───────────────┘ │
|
||||
│ ┌──────────────────────┐ ┌──────────────────┐ │
|
||||
│ │ AI-generated │ │ User Recipes │ │
|
||||
│ │ (Gemini) │ │ (будущее) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ source: 'ai' │ │ source: 'user' │ │
|
||||
│ └──────────┬───────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ └──────────┬────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ Единая таблица │ │
|
||||
│ │ recipes в БД │ │
|
||||
│ └────────────────┘ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ saved_recipes │ │
|
||||
│ │ (per-user) │ │
|
||||
│ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Импорт из Spoonacular
|
||||
### Принцип работы с рецептами
|
||||
|
||||
- **Начальный импорт:** 5 000–10 000 самых популярных рецептов (по рейтингу).
|
||||
- **Данные:** название, описание, ингредиенты с граммовками, шаги приготовления, калории, БЖУ, время, сложность, кухня, фото, теги.
|
||||
- **Хранение:** в нашей БД (PostgreSQL). Не зависим от Spoonacular API при показе рецептов пользователю.
|
||||
- **Синхронизация:** фоновый джоб раз в неделю — обновление данных, добавление новых рецептов из популярных.
|
||||
- **Spoonacular API** используется также для: расширенного поиска (когда в локальной БД нет подходящего), справочника нутриентов, данных по ингредиентам.
|
||||
- **Локализация:** рецепты из Spoonacular на английском. Перевод — через Gemini Flash-Lite (batch, оффлайн). Переведённые рецепты кешируются в БД.
|
||||
- **Генерация:** Gemini создаёт рецепты с нуля на основе контекста пользователя (продукты, профиль, цели).
|
||||
- **Фото:** Pexels API по `image_query` (EN), которую Gemini включает в ответ.
|
||||
- **КБЖУ:** приблизительные значения от Gemini, помечаются «≈» в UI.
|
||||
- **Сохранение:** пользователь тапает ❤ → рецепт сохраняется в `saved_recipes`. До сохранения нигде не хранится.
|
||||
- **Отсутствие глобального каталога:** каждый пользователь видит только свои сохранённые рецепты и свежие рекомендации. Полноценный каталог с поиском — в TODO.md (требует постоянной базы).
|
||||
|
||||
### Согласованность AI и рецептов
|
||||
### Согласованность рецептов
|
||||
|
||||
**Критическое правило:** AI при подборе меню работает ТОЛЬКО с рецептами из нашей БД.
|
||||
|
||||
Поток:
|
||||
1. Backend выбирает кандидатов из БД по фильтрам (SQL-запрос: кухня, сложность, калории, ингредиенты).
|
||||
2. Backend формирует промпт с ID и метаданными кандидатов.
|
||||
3. AI ранжирует и компонует меню, возвращая `recipe_id`.
|
||||
4. Backend подгружает полные данные рецептов по ID.
|
||||
|
||||
Это гарантирует: точные нутриенты, наличие фото, корректные шаги, возможность оставить отзыв.
|
||||
|
||||
**Исключение:** AI-генерация нового рецепта «из того, что есть» — когда в БД нет подходящего. Такой рецепт сохраняется в БД как `source = 'ai'` и доступен другим пользователям после модерации (по рейтингу).
|
||||
- Все данные рецепта (ингредиенты, шаги, КБЖУ, image_url) приходят от Gemini+Pexels в одном запросе.
|
||||
- Сохранённый рецепт хранится целиком в `saved_recipes` как JSONB — нет риска рассинхронизации.
|
||||
- КБЖУ помечены как приблизительные — пользователь понимает точность данных.
|
||||
|
||||
---
|
||||
|
||||
@@ -591,7 +585,6 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
│ (JSONB) │ │ │──────────────────│
|
||||
│ created_at │ │ │ id │
|
||||
└──────────────┘ │ │ source │
|
||||
│ │ spoonacular_id │
|
||||
│ │ title │
|
||||
│ │ description │
|
||||
│ │ cuisine │
|
||||
@@ -609,7 +602,7 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
│ │ tags (JSONB) │
|
||||
│ │ avg_rating │
|
||||
│ │ review_count │
|
||||
│ │ created_by │──► users (NULL для spoonacular)
|
||||
│ │ created_by │──► users (NULL для ai-generated)
|
||||
│ └──────────────────┘
|
||||
│
|
||||
┌──────────────────┤ ┌──────────────────┐
|
||||
@@ -678,7 +671,7 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
│────────────────────────────│
|
||||
│ id │
|
||||
│ canonical_name │
|
||||
│ spoonacular_id (UNIQUE) │
|
||||
│ canonical_name_ru │
|
||||
│ aliases (JSONB) │
|
||||
│ category │
|
||||
│ default_unit │
|
||||
@@ -694,13 +687,13 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
### Ключевые решения по схеме
|
||||
|
||||
- **`products.mapping_id`** — FK на `ingredient_mappings`. Связывает продукт пользователя с каноническим ингредиентом. Через эту связь определяется наличие ингредиентов рецепта в запасах. Может быть NULL (если продукт не удалось сопоставить).
|
||||
- **`ingredient_mappings`** — каноническая таблица ингредиентов. `aliases` (JSONB) содержит все варианты написания на разных языках. `spoonacular_id` связывает с ингредиентами рецептов из Spoonacular. Нутриенты на 100г используются для пересчёта калорийности AI-генерированных рецептов. `storage_days` — дефолтный срок хранения для категории.
|
||||
- **`ingredient_mappings`** — каноническая таблица ингредиентов. `canonical_name_ru` — русское название. `aliases` (JSONB) содержит все варианты написания на разных языках. Нутриенты на 100г используются для пересчёта калорийности при необходимости. `storage_days` — дефолтный срок хранения для категории. Наполняется вручную (seed) и автоматически через Gemini при нераспознанных продуктах.
|
||||
- **`ingredient_substitutions`** — кеш замен ингредиентов. Ссылается на `ingredient_mappings` по обоим ингредиентам (оригинал и замена). Один раз определённая AI-замена переиспользуется для всех пользователей.
|
||||
- **`products.storage_days`** — период хранения после покупки (не фиксированная дата). `expires_at` вычисляется как `added_at + storage_days`. При отображении: «осталось X дней». Дефолт берётся из `ingredient_mappings.storage_days` при привязке.
|
||||
- **`products.unit`** — ENUM: g, kg, ml, l, pcs, bunch, pack. Фронтенд отображает локализованные названия. Дефолт берётся из `ingredient_mappings.default_unit`.
|
||||
- **`recipes.ingredients`** — JSONB массив: `[{ "name": "...", "amount": 200, "unit": "g", "optional": false }]`. JSONB позволяет гибко хранить без нормализации, при этом поддерживая поиск (`@>` оператор).
|
||||
- **`recipes.steps`** — JSONB массив: `[{ "order": 1, "title": "...", "description": "...", "image_url": "...", "timer_seconds": null }]`. `timer_seconds` не null → на шаге отображается таймер.
|
||||
- **`recipes.source`** — ENUM: spoonacular, ai, user. Определяет происхождение рецепта.
|
||||
- **`recipes.source`** — ENUM: ai, user. Определяет происхождение рецепта.
|
||||
- **`meal_diary.source`** — откуда записано: из меню, через фото, вручную, из рецепта.
|
||||
- **`shopping_lists.items`** — JSONB: `[{ "name": "...", "amount": 1, "unit": "l", "category": "dairy", "checked": false, "manual": false }]`.
|
||||
|
||||
@@ -744,24 +737,20 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
|
||||
Все `/ai/*` эндпоинты возвращают `{ task_id }` (HTTP 202 Accepted). Клиент получает результат через polling `/ai/tasks/{id}` или через WebSocket (будущее улучшение). Это позволяет обрабатывать запросы асинхронно через очереди.
|
||||
|
||||
### Рецепты
|
||||
### Рекомендации
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| GET | `/recipes` | Каталог (фильтры, поиск, пагинация) |
|
||||
| GET | `/recipes/{id}` | Карточка рецепта |
|
||||
| GET | `/recipes/recommended` | Персональные рекомендации |
|
||||
| GET | `/recipes/recent` | Недавно приготовленные |
|
||||
| POST | `/recipes` | Создать пользовательский рецепт |
|
||||
| POST | `/recipes/{id}/favorite` | Добавить в избранное |
|
||||
| DELETE | `/recipes/{id}/favorite` | Убрать из избранного |
|
||||
| GET | `/recommendations?count=5` | Персональные рекомендации (Gemini + Pexels) |
|
||||
|
||||
### Отзывы
|
||||
### Сохранённые рецепты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| GET | `/recipes/{id}/reviews` | Отзывы к рецепту |
|
||||
| POST | `/recipes/{id}/reviews` | Написать отзыв |
|
||||
| GET | `/saved-recipes` | Список сохранённых рецептов |
|
||||
| POST | `/saved-recipes` | Сохранить рецепт |
|
||||
| GET | `/saved-recipes/{id}` | Карточка сохранённого рецепта |
|
||||
| DELETE | `/saved-recipes/{id}` | Удалить из сохранённых |
|
||||
|
||||
### Меню
|
||||
|
||||
@@ -821,11 +810,11 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|
||||
|-----------|--------|---------|---------|
|
||||
| Firebase Auth | $0 | $0 | $0 (лимит 50K) |
|
||||
| Gemini API | $50–150 | $500–1 500 | $2 500–7 500 |
|
||||
| Spoonacular | $29 | $149 | $149 |
|
||||
| Pexels API | $0 | $0 | $0 (Free tier) |
|
||||
| Хостинг (Cloud Run / Fly.io) | $10–20 | $50–100 | $200–500 |
|
||||
| PostgreSQL (managed) | $15 | $50 | $150–300 |
|
||||
| S3 (фото) | $5 | $20 | $50–100 |
|
||||
| **Итого** | **$110–220** | **$770–1 820** | **$3 050–8 550** |
|
||||
| **Итого** | **$80–190** | **$620–1 670** | **$2 900–8 400** |
|
||||
|
||||
### Оптимизация затрат на AI
|
||||
|
||||
|
||||
Reference in New Issue
Block a user