Migrate easysite from api_es to api_yal

- Remove api_es service, Dockerfile, all Go source files
- Remove api_es from docker-compose.yml, nginx-ssl.conf, .env, Makefile
- Replace nginx /api/ proxy with /api/v1/ → api_yal:8787
- Add amenity/upload domains, AuthResponse, GET /auth/me, GET /objects/my to api_yal
- Rewrite easysite frontend: types, composables, and all 5 pages to use api_yal DTOs
- Wire nuxt.config public.apiBase, add useObjects CRUD composable
- Update docs references from api_es to api_yal
This commit is contained in:
valitovgaziz
2026-06-12 10:14:38 +05:00
parent 64295b689b
commit 90a96b4125
80 changed files with 1940 additions and 5103 deletions
-2
View File
@@ -12,5 +12,3 @@ ALL_DOMAINS=yalarba.ru,www.yalarba.ru,valitovgaziz.ru,www.valitovgaziz.ru,easysi
KEYCLOAK_ADMIN_PASSWORD=your_secure_password KEYCLOAK_ADMIN_PASSWORD=your_secure_password
KEYCLOAK_DB_PASSWORD=your_secure_db_password KEYCLOAK_DB_PASSWORD=your_secure_db_password
# API_ES port
API_ES_APP_PORT=8088
+1 -1
View File
@@ -30,7 +30,7 @@
│ Docker Compose Cluster │ │ Docker Compose Cluster │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_ES │ │ │ │ Nginx │ │ API_TP │ │ API_BB │ │ API_YAL │ │
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │ │ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │ │ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │ │ │ │ │ │ │
-15
View File
@@ -165,21 +165,6 @@ restart_analytics:
# Полный цикл обновления analytics # Полный цикл обновления analytics
analytics: stop_analitics git build_analititcs start_analytics wn analytics: stop_analitics git build_analititcs start_analytics wn
# Остановка api_es
stop_api_es:
docker compose down api_es
# Пересборка api_es
build_api_es:
docker compose build api_es --no-cache
# Запуск api_es
start_api_es:
docker compose up api_es -d
# Полный цикл обновления api_es
api_es: stop_api_es git build_api_es start_api_es wn
# Остановка certbot # Остановка certbot
stop_cerbot: stop_cerbot:
docker compose down certbot docker compose down certbot
+2 -2
View File
@@ -68,7 +68,7 @@ sites:
aliases: [www.easysite102.ru] aliases: [www.easysite102.ru]
type: container type: container
upstream: http://easysite:3000 upstream: http://easysite:3000
api: http://api_es:8088/ api: http://api_yal:8787/
begushiybashkir: begushiybashkir:
domain: begushiybashkir.ru domain: begushiybashkir.ru
@@ -165,7 +165,7 @@ sites:
aliases: [www.easysite102.ru] aliases: [www.easysite102.ru]
type: container type: container
upstream: http://easysite:3000 upstream: http://easysite:3000
api: http://api_es:8088/ api: http://api_yal:8787/
begushiybashkir: begushiybashkir:
domain: begushiybashkir.ru domain: begushiybashkir.ru
+2 -31
View File
@@ -53,8 +53,6 @@ services:
depends_on: depends_on:
easysite: easysite:
condition: service_healthy condition: service_healthy
api_es:
condition: service_healthy
certbot: certbot:
condition: service_healthy condition: service_healthy
api_tp: api_tp:
@@ -241,7 +239,7 @@ services:
timeout: 10s timeout: 10s
retries: 5 retries: 5
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_es REST API app # SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_yal REST API app
easysite: easysite:
build: build:
context: ./yalarba/easySite/easySite context: ./yalarba/easySite/easySite
@@ -254,6 +252,7 @@ services:
NODE_ENV: production NODE_ENV: production
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 3000 PORT: 3000
NUXT_PUBLIC_API_BASE: /api/v1
networks: networks:
- web-network - web-network
- app-network - app-network
@@ -263,34 +262,6 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
# REST API приложение для easysite102.ru тут бизнес логика и система для обращения к PostgresQL БД (тоже сервис db:db_tp)
api_es:
build:
context: ./yalarba/api_es
dockerfile: Dockerfile
container_name: api_es
restart: unless-stopped
env_file:
- ./yalarba/api_es/.env
depends_on:
db:
condition: service_healthy
environment:
DB_HOST: db
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: mydb
APP_PORT: ${API_ES_APP_PORT}
networks:
- app-network
- web-network
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:8088/health"]
interval: 30s
timeout: 10s
retries: 3
# REST API app on Golang для api_yal сервиса # REST API app on Golang для api_yal сервиса
api_yal: api_yal:
build: build:
+5 -5
View File
@@ -41,7 +41,7 @@
│ • certbot - SSL сертификаты │ │ • certbot - SSL сертификаты │
│ • analytics - Статистика (Node.js) │ │ • analytics - Статистика (Node.js) │
│ • api_tp - API yalarba.ru (Go) │ │ • api_tp - API yalarba.ru (Go) │
│ • api_es - API easysite102.ru (Go) │ │ • api_yal - API easysite102.ru (Go) │
│ • api_bb - API Бегущий Башкир (Go) │ │ • api_bb - API Бегущий Башкир (Go) │
│ • easysite - SPA (Nuxt.js) │ │ • easysite - SPA (Nuxt.js) │
│ • db - PostgreSQL (yalarba/easy) │ │ • db - PostgreSQL (yalarba/easy) │
@@ -74,7 +74,7 @@
|-------|-----|----------------|---------------| |-------|-----|----------------|---------------|
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` | | `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` |
| `valitovgaziz.ru` | Статический сайт | - | `/usr/share/nginx/valitovgaziz/html` | | `valitovgaziz.ru` | Статический сайт | - | `/usr/share/nginx/valitovgaziz/html` |
| `easysite102.ru` | SPA (Nuxt.js) | `easysite:3000` + `api_es:8088` | Прокси | | `easysite102.ru` | SPA (Nuxt.js) | `easysite:3000` + `api_yal:8787` | Прокси |
| `begushiybashkir.ru` | SPA (Vue) | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` | | `begushiybashkir.ru` | SPA (Vue) | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
| `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` | | `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
@@ -118,7 +118,7 @@
``` ```
EMAIL=admin@example.com # Для Let's Encrypt EMAIL=admin@example.com # Для Let's Encrypt
ALL_DOMAINS=yalarba.ru,valitovgaziz.ru... # Все домены для SSL ALL_DOMAINS=yalarba.ru,valitovgaziz.ru... # Все домены для SSL
API_ES_APP_PORT=8088 # Порт API easysite # API_ES убран, используется api_yal:8787
``` ```
### Сервисные ### Сервисные
@@ -141,14 +141,14 @@ STAGING=0 # 1 для тестового режима Let's Encrypt
| certbot | Проверка файла сертификата | - | 30s | | certbot | Проверка файла сертификата | - | 30s |
| analytics | `http://localhost:3000/health` | 3000 | 30s | | analytics | `http://localhost:3000/health` | 3000 | 30s |
| api_tp | `http://localhost:8080/health` | 8080 | 30s | | api_tp | `http://localhost:8080/health` | 8080 | 30s |
| api_es | `http://localhost:8088/health` | 8088 | 30s | | api_yal | `http://localhost:8787/health` | 8787 | 30s |
| api_bb | `http://localhost:8080/api/health` | 8080 | 30s | | api_bb | `http://localhost:8080/api/health` | 8080 | 30s |
| easysite | `http://localhost:3000/api/health` | 3000 | 30s | | easysite | `http://localhost:3000/api/health` | 3000 | 30s |
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s | | db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
### Зависимости запуска ### Зависимости запуска
Nginx запускается только после подтверждения здоровья: Nginx запускается только после подтверждения здоровья:
- `easysite`, `api_es`, `certbot`, `api_tp`, `api_bb`, `analytics` - `easysite`, `api_yal`, `certbot`, `api_tp`, `api_bb`, `analytics`
## Волумы ## Волумы
+3 -57
View File
@@ -231,82 +231,28 @@ server {
} }
# ============================================ # ============================================
# ЛОКАЦИЯ: API Backend для Easysite # ЛОКАЦИЯ: API Backend для Easysite (api_yal)
# ============================================ # ============================================
location /api/ { location /api/v1/ {
# Отдельный API endpoint для backend proxy_pass http://api_yal:8787;
proxy_pass http://api_es:8088/;
# Заголовки прокси
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Port $server_port;
# Таймауты как у основного приложения
proxy_connect_timeout 600; proxy_connect_timeout 600;
proxy_send_timeout 600; proxy_send_timeout 600;
proxy_read_timeout 600; proxy_read_timeout 600;
# ========================================
# ДЕТАЛЬНЫЕ НАСТРОЙКИ CORS ДЛЯ OPTIONS
# ========================================
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
# Динамический заголовок Origin из запроса
add_header 'Access-Control-Allow-Origin' "$http_origin"; add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE'; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
# Подробный список разрешенных заголовков
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization'; add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
# Время кэширования preflight ответа (20 дней)
add_header 'Access-Control-Max-Age' 1728000; add_header 'Access-Control-Max-Age' 1728000;
# Пустой ответ для OPTIONS
add_header 'Content-Length' 0; add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8'; add_header 'Content-Type' 'text/plain charset=UTF-8';
# Возвращаем 204 без тела ответа
return 204;
}
}
location /api_yal/ {
# Отдельный API endpoint для backend
proxy_pass http://api_yal:8787/;
# Заголовки прокси
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
# Таймауты как у основного приложения
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
# ========================================
# ДЕТАЛЬНЫЕ НАСТРОЙКИ CORS ДЛЯ OPTIONS
# ========================================
if ($request_method = OPTIONS ) {
# Динамический заголовок Origin из запроса
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
# Подробный список разрешенных заголовков
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
# Время кэширования preflight ответа (20 дней)
add_header 'Access-Control-Max-Age' 1728000;
# Пустой ответ для OPTIONS
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8';
# Возвращаем 204 без тела ответа
return 204; return 204;
} }
} }
-12
View File
@@ -1,12 +0,0 @@
# DB environment variabels
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=mydb
APP_PORT=8080
JWT_SECRET=secret
UPLOAD_PATH=./storage/uploads
ENVIRONMENT=development
LOG_LEVEL=debug
API_ES_APP_PORT=8088
-20
View File
@@ -1,20 +0,0 @@
FROM golang:1.25.1-alpine
WORKDIR /app
# Устанавливаем зависимости для компиляции
RUN apk add --no-cache gcc musl-dev
# Копируем go.mod и go.sum
COPY go.mod go.sum ./
RUN go mod download
# Копируем исходный код
COPY . .
# Компилируем БЕЗ CGO (указываем путь к main.go)
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/main ./cmd/main.go
EXPOSE 8081
CMD ["./bin/main"]
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"log"
"net/http"
"api_es/internal/config"
"api_es/internal/database"
"api_es/internal/router"
"api_es/pkg/logger"
"go.uber.org/zap"
)
func main() {
// Загрузка конфигурации приложения из файлов окружения или конфигурационных файлов
// Конфигурация включает параметры БД, уровень логирования, порт приложения и т.д.
cfg := config.Load()
// Инициализация логгера с указанным уровнем логирования и окружением (dev/prod)
// Логгер будет настроен соответствующим образом для заданного окружения
logger.Init(cfg.LogLevel, cfg.Environment)
// Получение инстанса логгера для использования во всем приложении
zapLogger := logger.Get()
// Логирование старта приложения с указанием используемого стека технологий
zapLogger.Info("Start api_es REST API on stack Golang (gorm, chi) and PostgresDB connect")
// Инициализация подключения к базе данных PostgreSQL с использованием параметров из конфигурации
// Возвращается объект gorm.DB для работы с ORM
db, err := database.NewPostgresConnection(cfg)
if err != nil {
// Критическая ошибка подключения к БД - приложение не может работать без БД
zapLogger.Panic("Failed to connect to database:", zap.Error(err))
}
// Получение низкоуровневого объекта *sql.DB из gorm.DB для выполнения операций,
// не поддерживаемых напрямую gorm (например, Ping)
sqlDB, err := db.DB()
if err != nil {
// Ошибка получения инстанса БД, но приложение может продолжить работу
zapLogger.Error("failed to get database instance", zap.Error(err))
}
// Проверка доступности базы данных через ping-запрос
// Убеждаемся, что соединение активно и БД отвечает
if err := sqlDB.Ping(); err != nil {
zapLogger.Error("database ping failed", zap.Error(err))
}
// Успешная проверка соединения с БД
zapLogger.Info("database ping successful")
// Настройка маршрутизатора (роутера) для обработки HTTP-запросов
// Передаем подключение к БД и конфигурацию для инициализации обработчиков
zapLogger.Info("setup router")
r := router.SetupRouter(db, cfg)
// Запуск HTTP-сервера на порту, указанном в конфигурации
// Сервер начинает прослушивать входящие соединения
zapLogger.Info("Server starting on port %s", zap.String("AppPort", cfg.AppPort))
log.Printf("Server starting on port %s", cfg.AppPort)
// Запуск HTTP-сервера с указанным роутером
// ListenAndServe блокирует выполнение и обрабатывает входящие запросы
// В случае ошибки запуска сервера, логируем ошибку и завершаем приложение
if err := http.ListenAndServe(":"+cfg.AppPort, r); err != nil {
log.Fatal("Failed to start server:", err)
}
}
@@ -1,259 +0,0 @@
# Документация REST API сервиса "Travel Platform"
## Общая информация
API сервиса для управления туристическими объектами (отели, санатории, достопримечательности и др.) с системой аутентификации пользователей, отзывами и фильтрацией.
## Базовый URL
`http://localhost:8080` (или другой хост/порт в зависимости от конфигурации)
---
## Модели данных
### Пользователь (User)
**Поля:**
- `id` - уникальный идентификатор
- `email` - электронная почта (уникальный)
- `password_hash` - хеш пароля
- `full_name`, `first_name`, `last_name` - имя пользователя
- `phone`, `city` - контактная информация
- `organization_*` - бизнес-данные для владельцев
- `is_active`, `is_verified`, `role` - статус и права доступа
### Объект (Object)
**Типы объектов:**
- `hotel` - отель
- `sanatorium` - санаторий
- `guest_house` - гостевой дом
- `tour` - тур
- `restaurant` - ресторан
- `museum` - музей
- `landmark` - достопримечательность
- `event` - мероприятие
- `route` - маршрут
**Статусы объектов:**
- `draft` - черновик
- `moderation` - на модерации
- `active` - активен
- `inactive` - неактивен
- `rejected` - отклонен
### Отзыв (Review)
- Оценка от 1 до 5 звезд
- Текстовый отзыв
- Связь с объектом и автором
---
## Аутентификация и авторизация
### Система токенов:
- **Access Token** - для доступа к защищенным ресурсам
- **Refresh Token** - для обновления access token
- **Token Type**: Bearer
- Токены передаются в заголовке `Authorization: Bearer <token>`
### Роли пользователей:
1. **user** - обычный пользователь
2. **moderator** - модератор
3. **admin** - администратор
---
## Эндпоинты API
### 1. Проверка работоспособности
**GET /health**
**GET /check**
*Проверка доступности сервиса*
### 2. Аутентификация
#### Регистрация пользователя
**POST /auth/register**
*Создание нового аккаунта*
**Тело запроса (UserRegisterRequest):**
```json
{
"email": "user@example.com",
"password": "password123",
"full_name": "Иван Иванов",
"phone": "+79991234567",
"city": "Москва"
}
```
#### Вход в систему
**POST /auth/login**
*Получение токенов доступа*
**Тело запроса (AuthRequest):**
```json
{
"email": "user@example.com",
"password": "password123"
}
```
**Ответ (AuthResponse):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"email": "user@example.com",
"full_name": "Иван Иванов"
// ... остальные поля UserResponse
}
}
```
#### Обновление токена
**POST /auth/refresh**
*Получение нового access token по refresh token*
**Тело запроса (RefreshTokenRequest):**
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
#### Выход из системы
**POST /auth/logout**
*Инвалидация токенов*
### 3. Профиль пользователя
#### Получение профиля
**GET /users/profile**
*Требуется аутентификация*
*Получение данных текущего пользователя*
#### Обновление профиля
**PUT /users/profile**
*Требуется аутентификация*
*Обновление данных пользователя*
### 4. Управление пользователями (Admin)
#### Список пользователей
**GET /users**
*Требуется роль admin*
*Получение списка всех пользователей*
#### Получение пользователя по ID
**GET /users/{id}**
*Требуется роль admin*
*Получение данных конкретного пользователя*
---
## Фильтрация и пагинация
Для эндпоинтов списков объектов поддерживается фильтрация через `ObjectFilter`:
**Параметры запроса:**
- `search` - текстовый поиск
- `type` - тип объекта (hotel, sanatorium и т.д.)
- `city` - город
- `min_price`, `max_price` - диапазон цен
- `min_rating` - минимальный рейтинг
- `status` - статус объекта
- `owner_id` - ID владельца
- `page` - номер страницы (начинается с 1)
- `page_size` - количество элементов на странице (1-100)
- `sort_by` - поле сортировки (title, price, rating, city, created_at)
- `sort_order` - порядок сортировки (asc, desc)
**Пример запроса:**
```
GET /objects?city=Москва&min_price=1000&max_price=5000&page=1&page_size=20&sort_by=price&sort_order=asc
```
---
## Формат ответа с пагинацией
Для списков возвращается `PaginatedResponse`:
```json
{
"data": [...], // массив объектов
"total": 150, // общее количество
"page": 1, // текущая страница
"page_size": 20, // элементов на странице
"total_pages": 8 // всего страниц
}
```
---
## Обработка ошибок
Сервис использует стандартные HTTP статусы:
- `200` - успешный запрос
- `201` - создан новый ресурс
- `400` - ошибка валидации
- `401` - неавторизован
- `403` - доступ запрещен
- `404` - ресурс не найден
- `500` - внутренняя ошибка сервера
---
## Следующие шаги (планируемые эндпоинты)
На основе моделей данных ожидаются следующие API:
### Управление объектами:
- `GET /objects` - список объектов с фильтрацией
- `GET /objects/{id}` - получение объекта
- `POST /objects` - создание объекта (требуется аутентификация)
- `PUT /objects/{id}` - обновление объекта
- `DELETE /objects/{id}` - удаление объекта
### Управление отзывами:
- `GET /objects/{id}/reviews` - отзывы объекта
- `POST /reviews` - создание отзыва
- `PUT /reviews/{id}` - обновление отзыва
- `DELETE /reviews/{id}` - удаление отзыва
### Модерация:
- `GET /moderation/objects` - объекты на модерации
- `POST /moderation/objects/{id}/approve` - утвердить объект
- `POST /moderation/objects/{id}/reject` - отклонить объект
### Отчеты и аналитика:
- `GET /reports/popular-objects` - популярные объекты
- `GET /reports/user-activity` - активность пользователей
- `GET /reviews/revenue` - аналитика доходов
---
## Технические детали
### База данных:
- Используется GORM (Go ORM)
- Поддерживаются миграции
- Soft delete для основных сущностей
### Логирование:
- Структурированное логирование через Zap
- Логирование маршрутов при запуске
- Middleware для логирования запросов
### Конфигурация:
- Централизованная конфигурация через `config.Config`
- Поддержка разных окружений
### Middleware:
- Аутентификация (`AuthMiddleware`)
- Авторизация по ролям (`AdminMiddleware`)
- Логирование
- Recovery от паник
-34
View File
@@ -1,34 +0,0 @@
module api_es
go 1.25.1
require (
github.com/go-chi/chi/v5 v5.2.3
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.10
)
require (
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.36.0 // indirect
)
require (
github.com/go-chi/cors v1.2.2
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.42.0
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
-62
View File
@@ -1,62 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
@@ -1,42 +0,0 @@
package config
import (
"os"
)
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
JWTSecret string
ServerPort string
UploadPath string
LogLevel string
Environment string
AppPort string
}
func Load() *Config {
return &Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "postgres"),
DBName: getEnv("DB_NAME", "mydb"),
JWTSecret: getEnv("JWT_SECRET", "secret"),
ServerPort: getEnv("SERVER_PORT", "8080"),
UploadPath: getEnv("UPLOAD_PATH", "./storage/uploads"),
LogLevel: getEnv("LOG_LEVEL", "debug"),
Environment: getEnv("ENVIRONMENT", "development"),
AppPort: getEnv("APP_PORT", "8088"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
@@ -1,29 +0,0 @@
package database
import (
"api_es/internal/models"
"api_es/pkg/logger"
"gorm.io/gorm"
)
func SeedInitialData(db *gorm.DB) error {
zapLogger := logger.Get()
zapLogger.Debug("start fill init data")
// Создание базовых удобств
amenities := []models.Amenity{
{Name: "Wi-Fi", Category: "basic", Icon: "wifi"},
{Name: "Парковка", Category: "basic", Icon: "parking"},
{Name: "Бассейн", Category: "comfort", Icon: "pool"},
// ... другие удобства
}
for _, amenity := range amenities {
if err := db.FirstOrCreate(&amenity, models.Amenity{Name: amenity.Name}).Error; err != nil {
return err
}
}
zapLogger.Debug("end fill init data")
return nil
}
@@ -1,60 +0,0 @@
package database
import (
"api_es/internal/config"
"api_es/internal/models"
"api_es/pkg/logger"
"fmt"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func NewPostgresConnection(cfg *config.Config) (*gorm.DB, error) {
zapLogger := logger.Get()
zapLogger.Info("Start connect to Postgres DB")
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort)
zapLogger.Info("dsn = %s", zap.String("dsn", dsn))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
zapLogger.Info("AutoMigrate models")
// Автомиграция
if err := autoMigrate(db); err != nil {
zapLogger.Error("can't migrate models, error = %s", zap.Error(err))
return nil, fmt.Errorf("can't migrate models, error = %s", err)
}
zapLogger.Info("Migrate complite successfully")
zapLogger.Info("Fill init data")
SeedInitialData(db)
zapLogger.Info("Successfully connected to database")
return db, nil
}
func autoMigrate(db *gorm.DB) error {
zapLogger := logger.Get()
zapLogger.Debug("Start migration")
models := []interface{}{
&models.User{},
&models.Object{},
&models.ObjectImage{},
&models.Amenity{},
&models.ObjectAmenity{},
}
for _, model := range models {
if err := db.AutoMigrate(model); err != nil {
return fmt.Errorf("failed to migrate %T: %w", model, err)
}
}
zapLogger.Debug("End migration seccessfully")
return nil
}
@@ -1,90 +0,0 @@
package dto
import (
"api_es/internal/models"
"time"
)
// RegisterRequest - запрос на регистрацию
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
FullName string `json:"full_name" validate:"required"`
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
Phone string `json:"phone"`
City string `json:"city"`
}
// LoginRequest - запрос на вход
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// UpdateUserRequest - запрос на обновление пользователя
type UpdateUserRequest struct {
FullName string `json:"full_name"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone"`
City string `json:"city"`
OrganizationForm string `json:"organization_form"`
OrganizationName string `json:"organization_name"`
OrganizationShort string `json:"organization_short"`
INN string `json:"inn"`
PersonalINN string `json:"personal_inn"`
}
// UserResponse - ответ с данными пользователя
type UserResponse struct {
ID uint `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone"`
City string `json:"city"`
OrganizationForm string `json:"organization_form"`
OrganizationName string `json:"organization_name"`
OrganizationShort string `json:"organization_short"`
INN string `json:"inn"`
PersonalINN string `json:"personal_inn"`
IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// AuthResponse - ответ с токеном
type AuthResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
// ToUserResponse преобразует модель в DTO
func ToUserResponse(user *models.User) UserResponse {
return UserResponse{
ID: user.ID,
Email: user.Email,
FullName: user.FullName,
FirstName: user.FirstName,
LastName: user.LastName,
Phone: user.Phone,
City: user.City,
OrganizationForm: user.OrganizationForm,
OrganizationName: user.OrganizationName,
OrganizationShort: user.OrganizationShort,
INN: user.INN,
PersonalINN: user.PersonalINN,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
Role: user.Role,
CreatedAt: user.CreatedAt,
}
}
// dto/auth.go (добавляем если нужно)
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
@@ -1,39 +0,0 @@
package handler
import (
"api_es/internal/config"
"api_es/internal/repository"
"api_es/internal/service"
"api_es/internal/utils"
"gorm.io/gorm"
)
type AllHandler struct {
userHandler *UserHandler
healthHandler *HealthHandler
}
func NewAllHandler(db *gorm.DB, cfg *config.Config) *AllHandler {
userRepo := repository.NewUserRepository(db)
userService := service.NewUserService(userRepo, utils.NewJWTUtil(cfg.JWTSecret))
userHandler := NewUserHandler(userService)
healthHandler := NewHealthHandler()
return &AllHandler{
userHandler: userHandler,
healthHandler: healthHandler,
}
}
func (h *AllHandler) UserHandler() *UserHandler {
return h.userHandler
}
func (h *AllHandler) HealthHandler() *HealthHandler {
return h.healthHandler
}
@@ -1,31 +0,0 @@
package handler
import (
"net/http"
"api_es/internal/utils"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"status": "ok",
"message": "Service is healthy",
}
utils.RespondWithJSON(w, http.StatusOK, response)
}
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"status": "ok",
"message": "API is working",
}
utils.RespondWithJSON(w, http.StatusOK, response)
}
@@ -1,317 +0,0 @@
package handler
import (
"api_es/internal/dto"
appMiddleware "api_es/internal/middleware"
"api_es/internal/service"
"api_es/internal/utils"
"api_es/pkg/logger"
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
)
type UserHandler struct {
userService service.UserService
validator *validator.Validate
}
func NewUserHandler(userService service.UserService) *UserHandler {
return &UserHandler{
userService: userService,
validator: validator.New(),
}
}
// Register godoc
// @Summary Register new user
// @Description Create a new user account
// @Tags auth
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "Register request"
// @Success 201 {object} dto.AuthResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /auth/register [post]
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
zapLogger := logger.Get()
zapLogger.Debug("Start register")
var req dto.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.validator.Struct(req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response, err := h.userService.Register(r.Context(), req)
if err != nil {
switch err {
case service.ErrUserAlreadyExists:
http.Error(w, "User already exists", http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Устанавливаем куку с токеном
appMiddleware.SetAuthCookie(w, response.Token)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
zapLogger.Debug("End register")
json.NewEncoder(w).Encode(response)
}
// Login godoc
// @Summary Login user
// @Description Authenticate user and get token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "Login request"
// @Success 200 {object} dto.AuthResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /auth/login [post]
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
zapLogger := logger.Get()
zapLogger.Debug("Start login")
var req dto.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.validator.Struct(req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response, err := h.userService.Login(r.Context(), req)
if err != nil {
switch err {
case service.ErrInvalidCredentials:
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Устанавливаем куку с токеном
appMiddleware.SetAuthCookie(w, response.Token)
w.Header().Set("Content-Type", "application/json")
zapLogger.Debug("End login")
json.NewEncoder(w).Encode(response)
}
// Добавляем новый метод для logout
// Logout godoc
// @Summary Logout user
// @Description Clear authentication cookies and tokens
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]string
// @Router /auth/logout [post]
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Очищаем auth cookie
appMiddleware.ClearAuthCookie(w)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Successfully logged out",
})
}
// Добавляем метод для обновления токена
// RefreshToken godoc
// @Summary Refresh authentication token
// @Description Refresh JWT token using refresh token or existing auth
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.AuthResponse
// @Failure 401 {object} map[string]string
// @Router /auth/refresh [post]
func (h *UserHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
user, err := h.userService.GetUserProfile(r.Context(), userID)
if err != nil {
http.Error(w, "User not found", http.StatusUnauthorized)
return
}
// Генерируем новый токен
// В реальном приложении здесь должна быть логика с refresh token
jwtUtil := utils.NewJWTUtil("secret")
newToken, err := jwtUtil.GenerateToken(userID, user.Email, user.Role)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Обновляем куку
appMiddleware.SetAuthCookie(w, newToken)
response := &dto.AuthResponse{
Token: newToken,
User: *user,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// GetProfile godoc
// @Summary Get user profile
// @Description Get current user profile
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.UserResponse
// @Failure 404 {object} map[string]string
// @Router /users/profile [get]
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
zapLogger := logger.Get()
zapLogger.Debug("GetProfile start debug level")
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
user, err := h.userService.GetUserProfile(r.Context(), userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
zapLogger.Debug("GetProfile end debug level")
json.NewEncoder(w).Encode(user)
}
// UpdateProfile godoc
// @Summary Update user profile
// @Description Update current user profile
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.UpdateUserRequest true "Update request"
// @Success 200 {object} dto.UserResponse
// @Failure 400 {object} map[string]string
// @Router /users/profile [put]
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.userService.UpdateUser(r.Context(), userID, req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// GetUser godoc
// @Summary Get user by ID
// @Description Get user details by ID (admin only)
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Success 200 {object} dto.UserResponse
// @Failure 404 {object} map[string]string
// @Router /users/{id} [get]
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.userService.GetUser(r.Context(), uint(id))
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// ListUsers godoc
// @Summary List users
// @Description Get paginated list of users (admin only)
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Limit" default(10)
// @Param offset query int false "Offset" default(0)
// @Success 200 {array} dto.UserResponse
// @Router /users [get]
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
zapLogger := logger.Get()
zapLogger.Debug("Debug start handler listUsers")
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
limit := 10
offset := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil {
offset = o
}
}
users, err := h.userService.ListUsers(r.Context(), limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
zapLogger.Debug("Debug end handler listUsers")
json.NewEncoder(w).Encode(users)
}
@@ -1,119 +0,0 @@
// auth.go
package middleware
import (
"api_es/internal/utils"
"api_es/pkg/logger"
"context"
"net/http"
"strings"
"go.uber.org/zap"
)
type contextKey string
const (
UserIDKey contextKey = "userID"
UserEmailKey contextKey = "userEmail"
UserRoleKey contextKey = "userRole"
)
// Cookie конфигурация
const (
AuthCookieName = "auth_token"
CookieMaxAge = 24 * 60 * 60 // 24 часа
)
func AuthMiddleware(next http.Handler) http.Handler {
zapLogger := logger.Get()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
zapLogger.Debug("Debug start AuthMiddleware")
var tokenString string
// Пробуем получить токен из заголовка Authorization
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
tokenString = strings.Replace(authHeader, "Bearer ", "", 1)
zapLogger.Debug("Token from Authorization header", zap.String("token", tokenString))
}
// Если токена нет в заголовке, пробуем получить из куки
if tokenString == "" {
cookie, err := r.Cookie(AuthCookieName)
if err == nil && cookie.Value != "" {
tokenString = cookie.Value
zapLogger.Debug("Token from cookie", zap.String("token", tokenString))
}
}
if tokenString == "" {
http.Error(w, "Authorization required", http.StatusUnauthorized)
return
}
// Валидируем токен
jwtUtil := utils.NewJWTUtil("secret")
claims, err := jwtUtil.ValidateToken(tokenString)
if err != nil {
// Если токен невалиден, удаляем куку
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
zapLogger.Debug("Debug end AuthMiddleware")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Вспомогательная функция для установки auth cookie
func SetAuthCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: token,
Path: "/",
MaxAge: CookieMaxAge,
HttpOnly: true,
Secure: true, // В production должно быть true
SameSite: http.SameSiteStrictMode,
})
}
// Вспомогательная функция для удаления auth cookie
func ClearAuthCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func AdminMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(UserRoleKey).(string)
if !ok || role != "admin" {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
@@ -1,45 +0,0 @@
package models
import (
)
// AuthRequest - запрос на аутентификацию
type AuthRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// AuthResponse - ответ с токенами
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"` // Bearer
ExpiresIn int64 `json:"expires_in"`
User UserResponse `json:"user"`
}
// RefreshTokenRequest - запрос на обновление токена
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// UserRegisterRequest - запрос на регистрацию
type UserRegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
FullName string `json:"full_name" binding:"required"`
Phone string `json:"phone"`
City string `json:"city"`
}
// PasswordResetRequest - запрос на сброс пароля
type PasswordResetRequest struct {
Email string `json:"email" binding:"required,email"`
}
// PasswordResetConfirmRequest - подтверждение сброса пароля
type PasswordResetConfirmRequest struct {
Token string `json:"token" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
@@ -1,29 +0,0 @@
package models
type ObjectFilter struct {
Search string `form:"search" json:"search"`
Type ObjectType `form:"type" json:"type"`
City string `form:"city" json:"city"`
MinPrice float64 `form:"min_price" json:"min_price"`
MaxPrice float64 `form:"max_price" json:"max_price"`
MinRating float64 `form:"min_rating" json:"min_rating"`
Status ObjectStatus `form:"status" json:"status"`
OwnerID uint `form:"owner_id" json:"owner_id"`
// Пагинация
Page int `form:"page" json:"page" binding:"min=1"`
PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
// Сортировка
SortBy string `form:"sort_by" json:"sort_by"` // title, price, rating, city, created_at
SortOrder string `form:"sort_order" json:"sort_order"` // asc, desc
}
// PaginatedResponse - общий ответ с пагинацией
type PaginatedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
@@ -1 +0,0 @@
package models
@@ -1,121 +0,0 @@
package models
import (
"gorm.io/gorm"
"time"
)
type ObjectType string
const (
ObjectTypeHotel ObjectType = "hotel"
ObjectTypeSanatorium ObjectType = "sanatorium"
ObjectTypeGuestHouse ObjectType = "guest_house"
ObjectTypeTour ObjectType = "tour"
ObjectTypeRestaurant ObjectType = "restaurant"
ObjectTypeMuseum ObjectType = "museum"
ObjectTypeLandmark ObjectType = "landmark"
ObjectTypeEvent ObjectType = "event"
ObjectTypeRoute ObjectType = "route"
)
type ObjectStatus string
const (
ObjectStatusDraft ObjectStatus = "draft"
ObjectStatusModeration ObjectStatus = "moderation"
ObjectStatusActive ObjectStatus = "active"
ObjectStatusInactive ObjectStatus = "inactive"
ObjectStatusRejected ObjectStatus = "rejected"
)
type Object struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Основная информация
Title string `gorm:"not null" json:"title"`
Type ObjectType `gorm:"not null" json:"type"`
Description string `gorm:"type:text" json:"description"`
// Локация
City string `gorm:"not null" json:"city"`
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
// Цена и условия
Price float64 `gorm:"default:0" json:"price"`
PricePeriod string `gorm:"default:'per_night'" json:"price_period"` // per_night, per_person, per_tour
// Статус и рейтинг
Status ObjectStatus `gorm:"default:draft" json:"status"`
Rating float64 `gorm:"default:0" json:"rating"`
ReviewCount int `gorm:"default:0" json:"review_count"`
ViewCount int `gorm:"default:0" json:"view_count"`
// Владелец
OwnerID uint `gorm:"not null;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"owner_id"`
Owner User `gorm:"foreignKey:OwnerID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"owner,omitempty"`
// Связи
Images []ObjectImage `gorm:"foreignKey:ObjectID" json:"images"`
Amenities []Amenity `gorm:"many2many:object_amenities;" json:"amenities"`
Reviews []Review `gorm:"foreignKey:ObjectID" json:"-"`
}
// ObjectImage представляет изображения объекта
type ObjectImage struct {
ID uint `gorm:"primaryKey" json:"id"`
ObjectID uint `gorm:"not null;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"object_id"`
URL string `gorm:"not null" json:"url"`
IsPrimary bool `gorm:"default:false" json:"is_primary"`
Order int `gorm:"default:0" json:"order"`
CreatedAt time.Time `json:"created_at"`
}
// Amenity представляет удобства объекта
type Amenity struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;not null" json:"name"`
Category string `json:"category"` // basic, comfort, safety, entertainment, etc.
Icon string `json:"icon"` // иконка для фронтенда
Description string `json:"description"`
}
// ObjectAmenity связь многие-ко-многим между Object и Amenity
type ObjectAmenity struct {
ObjectID uint `gorm:"primaryKey" json:"object_id"`
AmenityID uint `gorm:"primaryKey" json:"amenity_id"`
}
// ObjectCreateRequest - запрос на создание объекта
type ObjectCreateRequest struct {
Title string `json:"title" binding:"required"`
Type ObjectType `json:"type" binding:"required"`
Description string `json:"description" binding:"required"`
City string `json:"city" binding:"required"`
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Price float64 `json:"price"`
PricePeriod string `json:"price_period"`
AmenityIDs []uint `json:"amenity_ids"`
}
// ObjectUpdateRequest - запрос на обновление объекта
type ObjectUpdateRequest struct {
Title string `json:"title"`
Type ObjectType `json:"type"`
Description string `json:"description"`
City string `json:"city"`
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Price float64 `json:"price"`
PricePeriod string `json:"price_period"`
Status ObjectStatus `json:"status"`
AmenityIDs []uint `json:"amenity_ids"`
}
@@ -1 +0,0 @@
package models
@@ -1,28 +0,0 @@
package models
import (
"time"
)
type Review struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
// Связи
ObjectID uint `gorm:"not null" json:"object_id"`
Object Object `gorm:"foreignKey:ObjectID" json:"object,omitempty"`
AuthorID uint `gorm:"not null" json:"author_id"`
Author User `gorm:"foreignKey:AuthorID" json:"author"`
// Контент отзыва
Rating int `gorm:"not null;check:rating >= 1 AND rating <= 5" json:"rating"`
Text string `gorm:"type:text" json:"text"`
IsActive bool `gorm:"default:true" json:"is_active"`
}
// ReviewCreateRequest - запрос на создание отзыва
type ReviewCreateRequest struct {
ObjectID uint `json:"object_id" binding:"required"`
Rating int `json:"rating" binding:"required,min=1,max=5"`
Text string `json:"text" binding:"required,min=10"`
}
@@ -1,68 +0,0 @@
package models
import (
"gorm.io/gorm"
"time"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Основная информация
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
FullName string `gorm:"not null;default:'Unknown'" json:"full_name"`
FirstName string `gorm:"not null;default:'FirstName'" json:"first_name"`
LastName string `gorm:"not null;default:'LastName'" json:"last_name"`
Phone string `json:"phone"`
City string `json:"city"`
// Бизнес информация (для владельцев объектов)
OrganizationForm string `json:"organization_form"` // ИП, ООО и т.д.
OrganizationName string `json:"organization_name"`
OrganizationShort string `json:"organization_short"`
INN string `json:"inn"` // ИНН организации
PersonalINN string `json:"personal_inn"` // Личный ИНН
// Статус
IsActive bool `gorm:"default:true" json:"is_active"`
IsVerified bool `gorm:"default:false" json:"is_verified"`
Role string `gorm:"default:user" json:"role"` // user, admin, moderator
// Связи
Objects []Object `gorm:"foreignKey:OwnerID" json:"-"`
Reviews []Review `gorm:"foreignKey:AuthorID" json:"-"`
}
// UserStats представляет статистику пользователя
type UserStats struct {
UserID uint `gorm:"primaryKey" json:"user_id"`
TotalObjects int `gorm:"default:0" json:"total_objects"`
ActiveObjects int `gorm:"default:0" json:"active_objects"`
ModerationObjects int `gorm:"default:0" json:"moderation_objects"`
TotalReviews int `gorm:"default:0" json:"total_reviews"`
}
// UserResponse - структура для ответа API (без чувствительных данных)
type UserResponse struct {
ID uint `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone"`
City string `json:"city"`
OrganizationForm string `json:"organization_form"`
OrganizationName string `json:"organization_name"`
OrganizationShort string `json:"organization_short"`
INN string `json:"inn"`
PersonalINN string `json:"personal_inn"`
IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
Stats UserStats `json:"stats,omitempty"`
}
@@ -1,398 +0,0 @@
package repository
import (
"errors"
"gorm.io/gorm"
"api_es/internal/models"
)
var (
ErrObjectNotFound = errors.New("object not found")
)
type ObjectRepository interface {
// Основные операции
Create(object *models.Object) error
GetByID(id uint) (*models.Object, error)
Update(id uint, updates *models.ObjectUpdateRequest) error
Delete(id uint) error
List(filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error)
// Специфичные операции
GetByOwner(ownerID uint, filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error)
UpdateStatus(id uint, status models.ObjectStatus) error
IncrementViewCount(id uint) error
UpdateRating(id uint, rating float64, reviewCount int) error
// Работа с изображениями
AddImage(objectID uint, image *models.ObjectImage) error
RemoveImage(objectID uint, imageID uint) error
SetPrimaryImage(objectID uint, imageID uint) error
GetImages(objectID uint) ([]models.ObjectImage, error)
// Работа с удобствами
AddAmenities(objectID uint, amenityIDs []uint) error
RemoveAmenities(objectID uint, amenityIDs []uint) error
GetAmenities(objectID uint) ([]models.Amenity, error)
}
type ObjectFilter struct {
Type []models.ObjectType
City string
Status []models.ObjectStatus
OwnerID uint
MinPrice float64
MaxPrice float64
MinRating float64
AmenityIDs []uint
Search string
}
type Pagination struct {
Page int `form:"page" default:"1"`
PageSize int `form:"page_size" default:"20"`
}
type objectRepository struct {
db *gorm.DB
}
func NewObjectRepository(db *gorm.DB) ObjectRepository {
return &objectRepository{db: db}
}
// Create создает новый объект
func (r *objectRepository) Create(object *models.Object) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Создаем основной объект
if err := tx.Create(object).Error; err != nil {
return err
}
// Добавляем связи с удобствами, если они есть
if len(object.Amenities) > 0 {
if err := tx.Model(object).Association("Amenities").Append(object.Amenities); err != nil {
return err
}
}
return nil
})
}
// GetByID возвращает объект по ID с связанными данными
func (r *objectRepository) GetByID(id uint) (*models.Object, error) {
var object models.Object
err := r.db.
Preload("Owner", func(db *gorm.DB) *gorm.DB {
return db.Select("id, first_name, last_name, email, phone")
}).
Preload("Images", func(db *gorm.DB) *gorm.DB {
return db.Order("is_primary DESC, order ASC")
}).
Preload("Amenities").
First(&object, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrObjectNotFound
}
return nil, err
}
return &object, nil
}
// Update обновляет объект
func (r *objectRepository) Update(id uint, updates *models.ObjectUpdateRequest) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Обновляем основные поля
updateData := map[string]interface{}{}
if updates.Title != "" {
updateData["title"] = updates.Title
}
if updates.Type != "" {
updateData["type"] = updates.Type
}
if updates.Description != "" {
updateData["description"] = updates.Description
}
if updates.City != "" {
updateData["city"] = updates.City
}
if updates.Address != "" {
updateData["address"] = updates.Address
}
if updates.Latitude != 0 {
updateData["latitude"] = updates.Latitude
}
if updates.Longitude != 0 {
updateData["longitude"] = updates.Longitude
}
if updates.Price != 0 {
updateData["price"] = updates.Price
}
if updates.PricePeriod != "" {
updateData["price_period"] = updates.PricePeriod
}
if updates.Status != "" {
updateData["status"] = updates.Status
}
if len(updateData) > 0 {
if err := tx.Model(&models.Object{}).Where("id = ?", id).Updates(updateData).Error; err != nil {
return err
}
}
// Обновляем удобства, если переданы
if updates.AmenityIDs != nil {
var object models.Object
if err := tx.First(&object, id).Error; err != nil {
return err
}
var amenities []models.Amenity
if err := tx.Where("id IN ?", updates.AmenityIDs).Find(&amenities).Error; err != nil {
return err
}
if err := tx.Model(&object).Association("Amenities").Replace(amenities); err != nil {
return err
}
}
return nil
})
}
// Delete удаляет объект (мягкое удаление)
func (r *objectRepository) Delete(id uint) error {
result := r.db.Delete(&models.Object{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrObjectNotFound
}
return nil
}
// List возвращает список объектов с фильтрацией и пагинацией
func (r *objectRepository) List(filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error) {
var objects []models.Object
var total int64
query := r.db.Model(&models.Object{})
// Применяем фильтры
if filter != nil {
query = r.applyFilters(query, filter)
}
// Считаем общее количество
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Применяем пагинацию
if pagination != nil {
offset := (pagination.Page - 1) * pagination.PageSize
query = query.Offset(offset).Limit(pagination.PageSize)
}
// Загружаем данные с прелоадами
err := query.
Preload("Images", func(db *gorm.DB) *gorm.DB {
return db.Where("is_primary = ?", true).Limit(1)
}).
Preload("Amenities").
Order("created_at DESC").
Find(&objects).Error
if err != nil {
return nil, 0, err
}
return objects, total, nil
}
// GetByOwner возвращает объекты владельца
func (r *objectRepository) GetByOwner(ownerID uint, filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error) {
if filter == nil {
filter = &ObjectFilter{}
}
filter.OwnerID = ownerID
return r.List(filter, pagination)
}
// UpdateStatus обновляет статус объекта
func (r *objectRepository) UpdateStatus(id uint, status models.ObjectStatus) error {
result := r.db.Model(&models.Object{}).Where("id = ?", id).Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrObjectNotFound
}
return nil
}
// IncrementViewCount увеличивает счетчик просмотров
func (r *objectRepository) IncrementViewCount(id uint) error {
return r.db.Model(&models.Object{}).
Where("id = ?", id).
Update("view_count", gorm.Expr("view_count + ?", 1)).
Error
}
// UpdateRating обновляет рейтинг и количество отзывов
func (r *objectRepository) UpdateRating(id uint, rating float64, reviewCount int) error {
return r.db.Model(&models.Object{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"rating": rating,
"review_count": reviewCount,
}).Error
}
// AddImage добавляет изображение к объекту
func (r *objectRepository) AddImage(objectID uint, image *models.ObjectImage) error {
image.ObjectID = objectID
return r.db.Create(image).Error
}
// RemoveImage удаляет изображение объекта
func (r *objectRepository) RemoveImage(objectID uint, imageID uint) error {
result := r.db.Where("object_id = ? AND id = ?", objectID, imageID).Delete(&models.ObjectImage{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrObjectNotFound
}
return nil
}
// SetPrimaryImage устанавливает основное изображение
func (r *objectRepository) SetPrimaryImage(objectID uint, imageID uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Сбрасываем все is_primary для объекта
if err := tx.Model(&models.ObjectImage{}).
Where("object_id = ?", objectID).
Update("is_primary", false).Error; err != nil {
return err
}
// Устанавливаем новое основное изображение
result := tx.Model(&models.ObjectImage{}).
Where("object_id = ? AND id = ?", objectID, imageID).
Update("is_primary", true)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrObjectNotFound
}
return nil
})
}
// GetImages возвращает изображения объекта
func (r *objectRepository) GetImages(objectID uint) ([]models.ObjectImage, error) {
var images []models.ObjectImage
err := r.db.Where("object_id = ?", objectID).
Order("is_primary DESC, order ASC").
Find(&images).Error
return images, err
}
// AddAmenities добавляет удобства к объекту
func (r *objectRepository) AddAmenities(objectID uint, amenityIDs []uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var object models.Object
if err := tx.First(&object, objectID).Error; err != nil {
return err
}
var amenities []models.Amenity
if err := tx.Where("id IN ?", amenityIDs).Find(&amenities).Error; err != nil {
return err
}
return tx.Model(&object).Association("Amenities").Append(amenities)
})
}
// RemoveAmenities удаляет удобства у объекта
func (r *objectRepository) RemoveAmenities(objectID uint, amenityIDs []uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var object models.Object
if err := tx.First(&object, objectID).Error; err != nil {
return err
}
var amenities []models.Amenity
if err := tx.Where("id IN ?", amenityIDs).Find(&amenities).Error; err != nil {
return err
}
return tx.Model(&object).Association("Amenities").Delete(amenities)
})
}
// GetAmenities возвращает удобства объекта
func (r *objectRepository) GetAmenities(objectID uint) ([]models.Amenity, error) {
var amenities []models.Amenity
err := r.db.Joins("JOIN object_amenities ON amenities.id = object_amenities.amenity_id").
Where("object_amenities.object_id = ?", objectID).
Find(&amenities).Error
return amenities, err
}
// applyFilters применяет фильтры к запросу
func (r *objectRepository) applyFilters(query *gorm.DB, filter *ObjectFilter) *gorm.DB {
if len(filter.Type) > 0 {
query = query.Where("type IN ?", filter.Type)
}
if filter.City != "" {
query = query.Where("city = ?", filter.City)
}
if len(filter.Status) > 0 {
query = query.Where("status IN ?", filter.Status)
}
if filter.OwnerID != 0 {
query = query.Where("owner_id = ?", filter.OwnerID)
}
if filter.MinPrice > 0 {
query = query.Where("price >= ?", filter.MinPrice)
}
if filter.MaxPrice > 0 {
query = query.Where("price <= ?", filter.MaxPrice)
}
if filter.MinRating > 0 {
query = query.Where("rating >= ?", filter.MinRating)
}
if filter.Search != "" {
search := "%" + filter.Search + "%"
query = query.Where("title ILIKE ? OR description ILIKE ?", search, search)
}
// Фильтр по удобствам
if len(filter.AmenityIDs) > 0 {
query = query.Joins("JOIN object_amenities ON objects.id = object_amenities.object_id").
Where("object_amenities.amenity_id IN ?", filter.AmenityIDs)
}
return query
}
@@ -1,389 +0,0 @@
package repository
import (
"errors"
"gorm.io/gorm"
"api_es/internal/models"
)
var (
ErrReviewNotFound = errors.New("review not found")
ErrDuplicateReview = errors.New("user already has review for this object")
)
type ReviewRepository interface {
// Основные операции
Create(review *models.Review) error
GetByID(id uint) (*models.Review, error)
Update(id uint, updates map[string]interface{}) error
Delete(id uint) error
// Списки отзывов
GetByObject(objectID uint, pagination *Pagination) ([]models.Review, int64, error)
GetByAuthor(authorID uint, pagination *Pagination) ([]models.Review, int64, error)
GetByObjectAndAuthor(objectID, authorID uint) (*models.Review, error)
// Статистика
GetObjectRatingStats(objectID uint) (float64, int, error)
GetUserReviewStats(authorID uint) (int, float64, error)
// Административные методы
SetActive(id uint, isActive bool) error
GetAll(pagination *Pagination, filters *ReviewFilter) ([]models.Review, int64, error)
}
type ReviewFilter struct {
ObjectID uint
AuthorID uint
Rating int
IsActive *bool
MinRating int
MaxRating int
}
type reviewRepository struct {
db *gorm.DB
}
func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db}
}
// Create создает новый отзыв
func (r *reviewRepository) Create(review *models.Review) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Проверяем, не оставлял ли пользователь уже отзыв на этот объект
var existingReview models.Review
err := tx.Where("object_id = ? AND author_id = ?", review.ObjectID, review.AuthorID).
First(&existingReview).Error
if err == nil {
return ErrDuplicateReview
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// Создаем отзыв
if err := tx.Create(review).Error; err != nil {
return err
}
// Обновляем рейтинг объекта
return r.updateObjectRating(tx, review.ObjectID)
})
}
// GetByID возвращает отзыв по ID
func (r *reviewRepository) GetByID(id uint) (*models.Review, error) {
var review models.Review
err := r.db.
Preload("Author", func(db *gorm.DB) *gorm.DB {
return db.Select("id, first_name, last_name, avatar")
}).
Preload("Object", func(db *gorm.DB) *gorm.DB {
return db.Select("id, title, type")
}).
First(&review, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrReviewNotFound
}
return nil, err
}
return &review, nil
}
// Update обновляет отзыв
func (r *reviewRepository) Update(id uint, updates map[string]interface{}) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Получаем отзыв для получения object_id
var review models.Review
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrReviewNotFound
}
return err
}
// Обновляем отзыв
result := tx.Model(&models.Review{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrReviewNotFound
}
// Обновляем рейтинг объекта, если изменился рейтинг
if _, hasRating := updates["rating"]; hasRating {
return r.updateObjectRating(tx, review.ObjectID)
}
return nil
})
}
// Delete удаляет отзыв
func (r *reviewRepository) Delete(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Получаем отзыв для получения object_id
var review models.Review
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrReviewNotFound
}
return err
}
// Удаляем отзыв
result := tx.Delete(&models.Review{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrReviewNotFound
}
// Обновляем рейтинг объекта
return r.updateObjectRating(tx, review.ObjectID)
})
}
// GetByObject возвращает отзывы для объекта
func (r *reviewRepository) GetByObject(objectID uint, pagination *Pagination) ([]models.Review, int64, error) {
var reviews []models.Review
var total int64
query := r.db.Model(&models.Review{}).Where("object_id = ? AND is_active = ?", objectID, true)
// Считаем общее количество
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Применяем пагинацию
if pagination != nil {
offset := (pagination.Page - 1) * pagination.PageSize
query = query.Offset(offset).Limit(pagination.PageSize)
}
// Загружаем данные
err := query.
Preload("Author", func(db *gorm.DB) *gorm.DB {
return db.Select("id, first_name, last_name, avatar")
}).
Order("created_at DESC").
Find(&reviews).Error
if err != nil {
return nil, 0, err
}
return reviews, total, nil
}
// GetByAuthor возвращает отзывы пользователя
func (r *reviewRepository) GetByAuthor(authorID uint, pagination *Pagination) ([]models.Review, int64, error) {
var reviews []models.Review
var total int64
query := r.db.Model(&models.Review{}).Where("author_id = ?", authorID)
// Считаем общее количество
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Применяем пагинацию
if pagination != nil {
offset := (pagination.Page - 1) * pagination.PageSize
query = query.Offset(offset).Limit(pagination.PageSize)
}
// Загружаем данные
err := query.
Preload("Object", func(db *gorm.DB) *gorm.DB {
return db.Select("id, title, type, city")
}).
Order("created_at DESC").
Find(&reviews).Error
if err != nil {
return nil, 0, err
}
return reviews, total, nil
}
// GetByObjectAndAuthor возвращает отзыв конкретного пользователя для объекта
func (r *reviewRepository) GetByObjectAndAuthor(objectID, authorID uint) (*models.Review, error) {
var review models.Review
err := r.db.
Where("object_id = ? AND author_id = ?", objectID, authorID).
First(&review).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrReviewNotFound
}
return nil, err
}
return &review, nil
}
// GetObjectRatingStats возвращает статистику рейтинга для объекта
func (r *reviewRepository) GetObjectRatingStats(objectID uint) (float64, int, error) {
var stats struct {
AverageRating float64
ReviewCount int
}
err := r.db.Model(&models.Review{}).
Select("AVG(rating) as average_rating, COUNT(*) as review_count").
Where("object_id = ? AND is_active = ?", objectID, true).
Scan(&stats).Error
if err != nil {
return 0, 0, err
}
return stats.AverageRating, stats.ReviewCount, nil
}
// GetUserReviewStats возвращает статистику отзывов пользователя
func (r *reviewRepository) GetUserReviewStats(authorID uint) (int, float64, error) {
var stats struct {
ReviewCount int
AverageRating float64
}
err := r.db.Model(&models.Review{}).
Select("COUNT(*) as review_count, AVG(rating) as average_rating").
Where("author_id = ? AND is_active = ?", authorID, true).
Scan(&stats).Error
if err != nil {
return 0, 0, err
}
return stats.ReviewCount, stats.AverageRating, nil
}
// SetActive активирует/деактивирует отзыв
func (r *reviewRepository) SetActive(id uint, isActive bool) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Получаем отзыв для получения object_id
var review models.Review
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrReviewNotFound
}
return err
}
// Обновляем статус
result := tx.Model(&models.Review{}).Where("id = ?", id).Update("is_active", isActive)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrReviewNotFound
}
// Обновляем рейтинг объекта
return r.updateObjectRating(tx, review.ObjectID)
})
}
// GetAll возвращает все отзывы с фильтрацией (для админки)
func (r *reviewRepository) GetAll(pagination *Pagination, filters *ReviewFilter) ([]models.Review, int64, error) {
var reviews []models.Review
var total int64
query := r.db.Model(&models.Review{})
// Применяем фильтры
if filters != nil {
query = r.applyFilters(query, filters)
}
// Считаем общее количество
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Применяем пагинацию
if pagination != nil {
offset := (pagination.Page - 1) * pagination.PageSize
query = query.Offset(offset).Limit(pagination.PageSize)
}
// Загружаем данные
err := query.
Preload("Author", func(db *gorm.DB) *gorm.DB {
return db.Select("id, first_name, last_name, email")
}).
Preload("Object", func(db *gorm.DB) *gorm.DB {
return db.Select("id, title, type")
}).
Order("created_at DESC").
Find(&reviews).Error
if err != nil {
return nil, 0, err
}
return reviews, total, nil
}
// updateObjectRating обновляет рейтинг объекта
func (r *reviewRepository) updateObjectRating(tx *gorm.DB, objectID uint) error {
stats, _, err := r.GetObjectRatingStats(objectID)
if err != nil {
return err
}
count_ := int64(0)
// Обновляем рейтинг объекта
return tx.Model(&models.Object{}).
Where("id = ?", objectID).
Updates(map[string]interface{}{
"rating": stats,
"review_count": tx.Model(&models.Review{}).
Where("object_id = ? AND is_active = ?", objectID, true).
Count(&count_),
}).Error
}
// applyFilters применяет фильтры к запросу
func (r *reviewRepository) applyFilters(query *gorm.DB, filters *ReviewFilter) *gorm.DB {
if filters.ObjectID != 0 {
query = query.Where("object_id = ?", filters.ObjectID)
}
if filters.AuthorID != 0 {
query = query.Where("author_id = ?", filters.AuthorID)
}
if filters.Rating != 0 {
query = query.Where("rating = ?", filters.Rating)
}
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.MinRating > 0 {
query = query.Where("rating >= ?", filters.MinRating)
}
if filters.MaxRating > 0 {
query = query.Where("rating <= ?", filters.MaxRating)
}
return query
}
@@ -1,74 +0,0 @@
package repository
import (
"api_es/internal/models"
"context"
"gorm.io/gorm"
)
type UserRepository interface {
Create(ctx context.Context, user *models.User) error
GetByID(ctx context.Context, id uint) (*models.User, error)
GetByEmail(ctx context.Context, email string) (*models.User, error)
Update(ctx context.Context, user *models.User) error
Delete(ctx context.Context, id uint) error
List(ctx context.Context, limit, offset int) ([]*models.User, error)
GetUserStats(ctx context.Context, userID uint) (*models.UserStats, error)
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *userRepository) GetByID(ctx context.Context, id uint) (*models.User, error) {
var user models.User
err := r.db.WithContext(ctx).First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
var user models.User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) Update(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *userRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&models.User{}, id).Error
}
func (r *userRepository) List(ctx context.Context, limit, offset int) ([]*models.User, error) {
var users []*models.User
err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
func (r *userRepository) GetUserStats(ctx context.Context, userID uint) (*models.UserStats, error) {
var stats models.UserStats
err := r.db.WithContext(ctx).First(&stats, userID).Error
if err != nil {
return nil, err
}
return &stats, nil
}
@@ -1,66 +0,0 @@
package router
import (
"api_es/internal/config"
"api_es/pkg/logger"
"encoding/json"
"net/http"
"api_es/internal/handler"
appMiddleware "api_es/internal/middleware"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
zapLogger := logger.Get()
zapLogger.Debug("Start setup rounting")
r := chi.NewRouter()
// Initialize logger
baseLogger := logger.NewWrapper(logger.Get())
setupMiddlewares(r)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
})
h := handler.NewAllHandler(db, config)
// Health routes
r.Route("/", func(r chi.Router) {
r.Get("/health", h.HealthHandler().HealthCheck)
r.Get("/check", h.HealthHandler().Check)
})
// router.go (обновляем секцию auth routes)
r.Route("/auth", func(r chi.Router) {
r.Post("/register", h.UserHandler().Register)
r.Post("/login", h.UserHandler().Login)
r.Post("/logout", h.UserHandler().Logout)
r.Post("/refresh", h.UserHandler().RefreshToken)
})
r.Route("/users", func(r chi.Router) {
r.Use(appMiddleware.AuthMiddleware)
r.Get("/profile", h.UserHandler().GetProfile)
r.Put("/profile", h.UserHandler().UpdateProfile)
// Admin routes
r.With(appMiddleware.AdminMiddleware).Get("/", h.UserHandler().ListUsers)
r.With(appMiddleware.AdminMiddleware).Get("/{id}", h.UserHandler().GetUser)
})
zapLogger.Debug("End setup routing")
// Логируем все зарегистрированные маршруты
routeLogger := logger.NewRouteLogger(baseLogger)
routeLogger.LogRoutes(r)
return r
}
@@ -1,41 +0,0 @@
package router
import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
// setupMiddlewares — устанавливает общие middleware для роутера.
func setupMiddlewares(r *chi.Mux) {
// Логирование всех запросов
r.Use(middleware.Logger)
// Восстановление после паник
r.Use(middleware.Recoverer)
// Удаление завершающих слешей
r.Use(middleware.StripSlashes)
// Установка реального IP из заголовков (X-Forwarded-For, X-Real-IP)
r.Use(middleware.RealIP)
// Таймаут обработки запроса
r.Use(middleware.Timeout(30 * time.Second))
// Поддержка CORS
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://easysite102.ru", "http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // 5 минут
}))
// Можно добавить и другие кастомные middleware при необходимости
}
@@ -1,201 +0,0 @@
package service
import (
"context"
"errors"
"api_es/internal/dto"
"api_es/internal/models"
"api_es/internal/repository"
"api_es/internal/utils"
"api_es/pkg/logger"
"golang.org/x/crypto/bcrypt"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidPassword = errors.New("invalid password")
)
type UserService interface {
Register(ctx context.Context, req dto.RegisterRequest) (*dto.AuthResponse, error)
Login(ctx context.Context, req dto.LoginRequest) (*dto.AuthResponse, error)
GetUser(ctx context.Context, id uint) (*dto.UserResponse, error)
UpdateUser(ctx context.Context, id uint, req dto.UpdateUserRequest) (*dto.UserResponse, error)
DeleteUser(ctx context.Context, id uint) error
ListUsers(ctx context.Context, limit, offset int) ([]*dto.UserResponse, error)
GetUserProfile(ctx context.Context, id uint) (*dto.UserResponse, error)
}
type userService struct {
userRepo repository.UserRepository
jwtUtil *utils.JWTUtil
}
func NewUserService(userRepo repository.UserRepository, jwtUtil *utils.JWTUtil) UserService {
return &userService{
userRepo: userRepo,
jwtUtil: jwtUtil,
}
}
func (s *userService) Register(ctx context.Context, req dto.RegisterRequest) (*dto.AuthResponse, error) {
zapLogger := logger.Get()
zapLogger.Debug("Start register")
// Проверяем существование пользователя
existingUser, _ := s.userRepo.GetByEmail(ctx, req.Email)
if existingUser != nil {
return nil, ErrUserAlreadyExists
}
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Создаем пользователя
user := &models.User{
Email: req.Email,
PasswordHash: string(hashedPassword),
FullName: req.FullName,
FirstName: req.FirstName,
LastName: req.LastName,
Phone: req.Phone,
City: req.City,
IsActive: true,
IsVerified: false,
Role: "user",
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
// Генерируем токен
token, err := s.jwtUtil.GenerateToken(user.ID, user.Email, user.Role)
if err != nil {
return nil, err
}
userResponse := dto.ToUserResponse(user)
zapLogger.Debug("End register")
return &dto.AuthResponse{
Token: token,
User: userResponse,
}, nil
}
func (s *userService) Login(ctx context.Context, req dto.LoginRequest) (*dto.AuthResponse, error) {
zapLogger := logger.Get()
zapLogger.Debug("Start login")
// Находим пользователя по email
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
return nil, ErrInvalidCredentials
}
// Проверяем пароль
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return nil, ErrInvalidCredentials
}
// Проверяем активность пользователя
if !user.IsActive {
return nil, errors.New("account is deactivated")
}
// Генерируем токен
token, err := s.jwtUtil.GenerateToken(user.ID, user.Email, user.Role)
if err != nil {
return nil, err
}
userResponse := dto.ToUserResponse(user)
zapLogger.Debug("End login")
return &dto.AuthResponse{
Token: token,
User: userResponse,
}, nil
}
func (s *userService) GetUser(ctx context.Context, id uint) (*dto.UserResponse, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrUserNotFound
}
response := dto.ToUserResponse(user)
return &response, nil
}
func (s *userService) UpdateUser(ctx context.Context, id uint, req dto.UpdateUserRequest) (*dto.UserResponse, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrUserNotFound
}
// Обновляем поля
if req.FullName != "" {
user.FullName = req.FullName
}
if req.FirstName != "" {
user.FirstName = req.FirstName
}
if req.LastName != "" {
user.LastName = req.LastName
}
if req.Phone != "" {
user.Phone = req.Phone
}
if req.City != "" {
user.City = req.City
}
if req.OrganizationForm != "" {
user.OrganizationForm = req.OrganizationForm
}
if req.OrganizationName != "" {
user.OrganizationName = req.OrganizationName
}
if req.OrganizationShort != "" {
user.OrganizationShort = req.OrganizationShort
}
if req.INN != "" {
user.INN = req.INN
}
if req.PersonalINN != "" {
user.PersonalINN = req.PersonalINN
}
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err
}
response := dto.ToUserResponse(user)
return &response, nil
}
func (s *userService) DeleteUser(ctx context.Context, id uint) error {
return s.userRepo.Delete(ctx, id)
}
func (s *userService) ListUsers(ctx context.Context, limit, offset int) ([]*dto.UserResponse, error) {
users, err := s.userRepo.List(ctx, limit, offset)
if err != nil {
return nil, err
}
responses := make([]*dto.UserResponse, len(users))
for i, user := range users {
response := dto.ToUserResponse(user)
responses[i] = &response
}
return responses, nil
}
func (s *userService) GetUserProfile(ctx context.Context, id uint) (*dto.UserResponse, error) {
return s.GetUser(ctx, id)
}
@@ -1,27 +0,0 @@
package utils
// formatPace форматирует темп в строку "MM:SS"
func FormatPace(minutes, seconds int) string {
if seconds >= 60 {
minutes += seconds / 60
seconds = seconds % 60
}
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
}
// formatTwoDigits форматирует число в двузначную строку
func FormatTwoDigits(num int) string {
if num < 10 {
return "0" + string(rune(num+'0'))
}
return string(rune(num/10+'0')) + string(rune(num%10+'0'))
}
// formatTime форматирует время в строку "MM:SS"
func FormatTime(minutes, seconds int) string {
if seconds >= 60 {
minutes += seconds / 60
seconds = seconds % 60
}
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
}
@@ -1,53 +0,0 @@
package utils
import (
"time"
"github.com/golang-jwt/jwt/v4"
)
type JWTUtil struct {
secretKey string
}
type Claims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func NewJWTUtil(secretKey string) *JWTUtil {
return &JWTUtil{secretKey: secretKey}
}
func (j *JWTUtil) GenerateToken(userID uint, email, role string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(j.secretKey))
}
func (j *JWTUtil) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(j.secretKey), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrInvalidKey
}
@@ -1,20 +0,0 @@
// pkg/utils/response.go (дополнение)
package utils
import (
"encoding/json"
"net/http"
)
// RespondWithValidationError отправляет ответ с ошибками валидации
func RespondWithValidationError(w http.ResponseWriter, validationError error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
response := map[string]interface{}{
"error": "Validation failed",
"details": GetValidationErrors(validationError),
}
json.NewEncoder(w).Encode(response)
}
@@ -1,75 +0,0 @@
package utils
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
RespondWithJSON(w, statusCode, map[string]string{"error": message})
}
// DecodeJSONBody декодирует JSON тело запроса
func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
if r.Header.Get("Content-Type") != "application/json" {
return errors.New("Content-Type header is not application/json")
}
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("request body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("request body contains unknown field %s", fieldName)
case errors.Is(err, io.EOF):
return errors.New("request body must not be empty")
case err.Error() == "http: request body too large":
return errors.New("request body must not be larger than 1MB")
default:
return err
}
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("request body must only contain a single JSON object")
}
return nil
}
// GetUserIDFromContext извлекает userID из контекста
func GetUserIDFromContext(r *http.Request) (uint, bool) {
userID, ok := r.Context().Value("userID").(uint)
return userID, ok
}
@@ -1,398 +0,0 @@
// pkg/utils/validation.go
package utils
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// ValidationError представляет ошибку валидации
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationResult содержит результат валидации
type ValidationResult struct {
IsValid bool
Errors []ValidationError
}
// TagOptions содержит опции из тега validate
type TagOptions struct {
Required bool
Min *float64
Max *float64
MinInt *int64
MaxInt *int64
OneOf []string
Email bool
MaxLength *int
MinLength *int
Custom string
}
// ValidateStruct валидирует структуру на основе тегов validate
func ValidateStruct(s interface{}) error {
val := reflect.ValueOf(s)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return fmt.Errorf("ValidateStruct expects a struct, got %T", s)
}
var errors []ValidationError
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Пропускаем неэкспортируемые поля
if !field.CanInterface() {
continue
}
tag := fieldType.Tag.Get("validate")
if tag == "" {
continue
}
options := parseTagOptions(tag)
fieldName := getFieldName(fieldType)
// Валидация поля
if err := validateField(field, fieldName, options); err != nil {
errors = append(errors, err...)
}
}
if len(errors) > 0 {
return &ValidationResult{
IsValid: false,
Errors: errors,
}
}
return nil
}
// parseTagOptions парсит тег validate и возвращает опции
func parseTagOptions(tag string) TagOptions {
options := TagOptions{}
parts := strings.Split(tag, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
switch {
case part == "required":
options.Required = true
case part == "email":
options.Email = true
case strings.HasPrefix(part, "min="):
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
options.Min = &val
}
case strings.HasPrefix(part, "max="):
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
options.Max = &val
}
case strings.HasPrefix(part, "minint="):
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
options.MinInt = &val
}
case strings.HasPrefix(part, "maxint="):
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
options.MaxInt = &val
}
case strings.HasPrefix(part, "oneof="):
options.OneOf = strings.Split(part[6:], " ")
case strings.HasPrefix(part, "maxlen="):
if val, err := strconv.Atoi(part[7:]); err == nil {
options.MaxLength = &val
}
case strings.HasPrefix(part, "minlen="):
if val, err := strconv.Atoi(part[7:]); err == nil {
options.MinLength = &val
}
case strings.HasPrefix(part, "custom="):
options.Custom = part[7:]
}
}
return options
}
// getFieldName возвращает имя поля для сообщений об ошибках
func getFieldName(field reflect.StructField) string {
jsonTag := field.Tag.Get("json")
if jsonTag != "" {
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
return parts[0]
}
}
return field.Name
}
// validateField валидирует отдельное поле
func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
// Проверка required
if options.Required {
if isEmptyValue(field) {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "field is required",
})
return errors // Если поле обязательно и пустое, дальше не проверяем
}
}
// Если поле пустое и не обязательное, дальше не проверяем
if isEmptyValue(field) {
return errors
}
// Валидация в зависимости от типа поля
switch field.Kind() {
case reflect.String:
errors = append(errors, validateString(field.String(), fieldName, options)...)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
errors = append(errors, validateInt(field.Int(), fieldName, options)...)
case reflect.Float32, reflect.Float64:
errors = append(errors, validateFloat(field.Float(), fieldName, options)...)
case reflect.Struct:
// Для time.Time и других структур
if field.Type().String() == "time.Time" {
errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...)
}
}
return errors
}
// validateString валидирует строковые поля
func validateString(value, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
// Проверка email
if options.Email {
if !isValidEmail(value) {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "invalid email format",
})
}
}
// Проверка длины строки
if options.MinLength != nil && len(value) < *options.MinLength {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength),
})
}
if options.MaxLength != nil && len(value) > *options.MaxLength {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength),
})
}
// Проверка oneof
if len(options.OneOf) > 0 {
valid := false
for _, allowed := range options.OneOf {
if value == allowed {
valid = true
break
}
}
if !valid {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")),
})
}
}
return errors
}
// validateInt валидирует целочисленные поля
func validateInt(value int64, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
if options.MinInt != nil && value < *options.MinInt {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("minimum value is %d", *options.MinInt),
})
}
if options.MaxInt != nil && value > *options.MaxInt {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("maximum value is %d", *options.MaxInt),
})
}
return errors
}
// validateFloat валидирует поля с плавающей точкой
func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
if options.Min != nil && value < *options.Min {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("minimum value is %.2f", *options.Min),
})
}
if options.Max != nil && value > *options.Max {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("maximum value is %.2f", *options.Max),
})
}
return errors
}
// validateTime валидирует временные поля
func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
// Проверка, что дата не нулевая
if value.IsZero() && options.Required {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "date is required",
})
}
// Проверка, что дата не в будущем (пример кастомной валидации)
if options.Custom == "not_future" && value.After(time.Now()) {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "date cannot be in the future",
})
}
return errors
}
// isEmptyValue проверяет, является ли значение пустым
func isEmptyValue(v reflect.Value) bool {
switch v.Kind() {
case reflect.String:
return v.String() == ""
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Struct:
if v.Type().String() == "time.Time" {
return v.Interface().(time.Time).IsZero()
}
case reflect.Ptr, reflect.Interface:
return v.IsNil()
case reflect.Slice, reflect.Map, reflect.Array:
return v.Len() == 0
}
return false
}
// isValidEmail проверяет валидность email
func isValidEmail(email string) bool {
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(emailRegex, email)
return matched
}
// Error возвращает строковое представление ошибок валидации
func (vr *ValidationResult) Error() string {
var errorMessages []string
for _, err := range vr.Errors {
errorMessages = append(errorMessages, err.Error())
}
return strings.Join(errorMessages, "; ")
}
// GetValidationErrors возвращает ошибки валидации в структурированном виде
func GetValidationErrors(err error) []ValidationError {
if vr, ok := err.(*ValidationResult); ok {
return vr.Errors
}
return nil
}
// LogValidationErrors логирует ошибки валидации
func LogValidationErrors(logger *zap.Logger, err error, context string) {
if vr, ok := err.(*ValidationResult); ok {
for _, validationErr := range vr.Errors {
logger.Warn("validation error",
zap.String("context", context),
zap.String("field", validationErr.Field),
zap.String("error", validationErr.Message),
)
}
}
}
// ParseUintFromQuery парсит uint из query параметра
func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) {
if queryParam == "" {
return defaultValue, nil
}
value, err := strconv.ParseUint(queryParam, 10, 32)
if err != nil {
return defaultValue, err
}
return uint(value), nil
}
// ParseIntFromQuery парсит int из query параметра
func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) {
if queryParam == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(queryParam)
if err != nil {
return defaultValue, err
}
return value, nil
}
// ParseBoolFromQuery парсит bool из query параметра
func ParseBoolFromQuery(queryParam string, defaultValue bool) bool {
if queryParam == "" {
return defaultValue
}
return strings.ToLower(queryParam) == "true" || queryParam == "1"
}
@@ -1,35 +0,0 @@
// pkg/logger/helpers.go
package logger
import (
"time"
"go.uber.org/zap"
)
// LogApplicationStart логирует запуск приложения
func LogApplicationStart(version, environment, port string) {
Get().Info("application starting",
zap.String("version", version),
zap.String("environment", environment),
zap.String("port", port),
zap.Time("start_time", time.Now()),
)
}
// LogApplicationShutdown логирует graceful shutdown
func LogApplicationShutdown(reason string) {
Get().Info("application shutting down",
zap.String("reason", reason),
zap.Time("shutdown_time", time.Now()),
)
}
// LogDatabaseStats логирует статистику базы данных
func LogDatabaseStats(stats map[string]interface{}) {
fields := make([]zap.Field, 0, len(stats))
for key, value := range stats {
fields = append(fields, zap.Any(key, value))
}
Get().Info("database statistics", fields...)
}
@@ -1,75 +0,0 @@
// pkg/logger/interface.go
package logger
import "go.uber.org/zap"
// LoggerInterface определяет контракт для логгера
type LoggerInterface interface {
Debug(msg string, fields ...zap.Field)
Info(msg string, fields ...zap.Field)
Warn(msg string, fields ...zap.Field)
Error(msg string, fields ...zap.Field)
Fatal(msg string, fields ...zap.Field)
Debugf(template string, args ...interface{})
Infof(template string, args ...interface{})
Warnf(template string, args ...interface{})
Errorf(template string, args ...interface{})
Fatalf(template string, args ...interface{})
With(fields ...zap.Field) LoggerInterface
}
// wrapper обертка для zap.Logger
type wrapper struct {
logger *zap.Logger
}
// NewWrapper создает новую обертку
func NewWrapper(logger *zap.Logger) LoggerInterface {
return &wrapper{logger: logger}
}
func (w *wrapper) Debug(msg string, fields ...zap.Field) {
w.logger.Debug(msg, fields...)
}
func (w *wrapper) Info(msg string, fields ...zap.Field) {
w.logger.Info(msg, fields...)
}
func (w *wrapper) Warn(msg string, fields ...zap.Field) {
w.logger.Warn(msg, fields...)
}
func (w *wrapper) Error(msg string, fields ...zap.Field) {
w.logger.Error(msg, fields...)
}
func (w *wrapper) Fatal(msg string, fields ...zap.Field) {
w.logger.Fatal(msg, fields...)
}
func (w *wrapper) Debugf(template string, args ...interface{}) {
w.logger.Sugar().Debugf(template, args...)
}
func (w *wrapper) Infof(template string, args ...interface{}) {
w.logger.Sugar().Infof(template, args...)
}
func (w *wrapper) Warnf(template string, args ...interface{}) {
w.logger.Sugar().Warnf(template, args...)
}
func (w *wrapper) Errorf(template string, args ...interface{}) {
w.logger.Sugar().Errorf(template, args...)
}
func (w *wrapper) Fatalf(template string, args ...interface{}) {
w.logger.Sugar().Fatalf(template, args...)
}
func (w *wrapper) With(fields ...zap.Field) LoggerInterface {
return &wrapper{logger: w.logger.With(fields...)}
}
@@ -1,66 +0,0 @@
// pkg/logger/logger.go
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var globalLogger *zap.Logger
// Init инициализирует глобальный логгер
func Init(level string, environment string) error {
var config zap.Config
if environment == "production" {
config = zap.NewProductionConfig()
} else {
config = zap.NewDevelopmentConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
// Устанавливаем уровень логирования
switch level {
case "debug":
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
case "info":
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
case "warn":
config.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
case "error":
config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
default:
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
}
logger, err := config.Build()
if err != nil {
return err
}
globalLogger = logger
return nil
}
// Get возвращает глобальный логгер
func Get() *zap.Logger {
if globalLogger == nil {
// Fallback на стандартный логгер если не инициализирован
logger, _ := zap.NewProduction()
return logger
}
return globalLogger
}
// Sync синхронизирует буферы логгера
func Sync() {
if globalLogger != nil {
globalLogger.Sync()
}
}
// Sugar возвращает SugaredLogger
func Sugar() *zap.SugaredLogger {
return Get().Sugar()
}
@@ -1,100 +0,0 @@
package logger
import (
"net/http"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
type RouteLogger struct {
logger LoggerInterface
}
func NewRouteLogger(log LoggerInterface) *RouteLogger {
return &RouteLogger{
logger: log,
}
}
func (rl *RouteLogger) LogRoutes(router *chi.Mux) {
routes := rl.extractRoutes(router)
rl.printFormattedRoutes(routes)
}
func (rl *RouteLogger) extractRoutes(router *chi.Mux) []RouteInfo {
var routes []RouteInfo
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
if route != "" {
routes = append(routes, RouteInfo{
Method: method,
Path: route,
})
}
return nil
}
if err := chi.Walk(router, walkFunc); err != nil {
rl.logger.Error("Failed to walk routes", zap.Error(err))
}
return routes
}
func (rl *RouteLogger) printFormattedRoutes(routes []RouteInfo) {
if len(routes) == 0 {
rl.logger.Info("No routes found")
return
}
// Группируем по пути
routesByPath := make(map[string][]string)
for _, route := range routes {
routesByPath[route.Path] = append(routesByPath[route.Path], route.Method)
}
// Сортируем пути
var paths []string
for path := range routesByPath {
paths = append(paths, path)
}
sort.Strings(paths)
rl.logger.Info("📋 Registered API Routes:")
rl.logger.Info("┌──────────────────────────────────────────────────────────────┐")
for _, path := range paths {
methods := routesByPath[path]
sort.Strings(methods)
methodsStr := strings.Join(methods, ", ")
if len(methodsStr) > 12 {
methodsStr = methodsStr[:9] + "..."
}
methodField := methodsStr
if len(methodField) < 12 {
methodField = methodField + strings.Repeat(" ", 12-len(methodField))
}
pathField := path
if len(pathField) > 45 {
pathField = pathField[:42] + "..."
} else {
pathField = pathField + strings.Repeat(" ", 45-len(pathField))
}
rl.logger.Info("│ " + methodField + " " + pathField + " │")
}
rl.logger.Info("└──────────────────────────────────────────────────────────────┘")
rl.logger.Info("Total routes registered: %d", zap.Int("count", len(routes)))
}
type RouteInfo struct {
Method string
Path string
}
-6
View File
@@ -1,6 +0,0 @@
# EasySite BackEnd
## Stack golang gorm chi
models:
user, object
@@ -44,6 +44,8 @@ func autoMigrate(db *gorm.DB) error {
&models.Account{}, &models.Account{},
&models.UpdateHistory{}, &models.UpdateHistory{},
&models.Object{}, &models.Object{},
&models.ObjectImage{},
&models.Amenity{},
&models.RatingVote{}, &models.RatingVote{},
&models.VoteBreakdown{}, &models.VoteBreakdown{},
&models.Rating{}, &models.Rating{},
@@ -0,0 +1,35 @@
package amenity
import "api_yal/internal/models"
type CreateAmenityRequest struct {
Name string `json:"name" validate:"required"`
Category string `json:"category"`
Icon string `json:"icon"`
Description string `json:"description"`
}
type UpdateAmenityRequest struct {
Name *string `json:"name"`
Category *string `json:"category"`
Icon *string `json:"icon"`
Description *string `json:"description"`
}
type AmenityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
}
func ToAmenityResponse(a *models.Amenity) AmenityResponse {
return AmenityResponse{
ID: a.ID,
Name: a.Name,
Category: a.Category,
Icon: a.Icon,
Description: a.Description,
}
}
@@ -0,0 +1,165 @@
package amenity
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"api_yal/internal/logger"
"api_yal/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
"go.uber.org/zap"
)
type AmenityHandler struct {
service AmenityService
validator *validator.Validate
}
func NewHandler(service AmenityService) *AmenityHandler {
return &AmenityHandler{
service: service,
validator: validator.New(),
}
}
func (h *AmenityHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateAmenityRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.validator.Struct(req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp, err := h.service.Create(&req)
if err != nil {
logger.Get().Error("failed to create amenity", zap.Error(err))
http.Error(w, "Failed to create amenity", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
func (h *AmenityHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, "Invalid amenity ID", http.StatusBadRequest)
return
}
resp, err := h.service.GetByID(uint(id))
if err != nil {
if errors.Is(err, errors.New("amenity not found")) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, "Failed to get amenity", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(resp)
}
func (h *AmenityHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, "Invalid amenity ID", http.StatusBadRequest)
return
}
var req UpdateAmenityRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
resp, err := h.service.Update(uint(id), &req)
if err != nil {
logger.Get().Error("failed to update amenity", zap.Error(err))
http.Error(w, "Failed to update amenity", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(resp)
}
func (h *AmenityHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, "Invalid amenity ID", http.StatusBadRequest)
return
}
if err := h.service.Delete(uint(id)); err != nil {
logger.Get().Error("failed to delete amenity", zap.Error(err))
http.Error(w, "Failed to delete amenity", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AmenityHandler) List(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
var resp []AmenityResponse
var err error
if category != "" {
resp, err = h.service.ListByCategory(category)
} else {
resp, err = h.service.List()
}
if err != nil {
logger.Get().Error("failed to list amenities", zap.Error(err))
http.Error(w, "Failed to list amenities", http.StatusInternalServerError)
return
}
if resp == nil {
resp = []AmenityResponse{}
}
json.NewEncoder(w).Encode(resp)
}
func (h *AmenityHandler) GetByObject(w http.ResponseWriter, r *http.Request) {
objectID, err := strconv.ParseUint(chi.URLParam(r, "objectId"), 10, 32)
if err != nil {
http.Error(w, "Invalid object ID", http.StatusBadRequest)
return
}
resp, err := h.service.GetByObject(uint(objectID))
if err != nil {
logger.Get().Error("failed to get object amenities", zap.Error(err))
http.Error(w, "Failed to get object amenities", http.StatusInternalServerError)
return
}
if resp == nil {
resp = []AmenityResponse{}
}
json.NewEncoder(w).Encode(resp)
}
func (h *AmenityHandler) ReplaceObjectAmenities(w http.ResponseWriter, r *http.Request) {
objectID, err := strconv.ParseUint(chi.URLParam(r, "objectId"), 10, 32)
if err != nil {
http.Error(w, "Invalid object ID", http.StatusBadRequest)
return
}
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
_ = userID
var req struct {
AmenityIDs []uint `json:"amenity_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.service.ReplaceObjectAmenities(uint(objectID), req.AmenityIDs); err != nil {
logger.Get().Error("failed to replace object amenities", zap.Error(err))
http.Error(w, "Failed to replace amenities", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Amenities updated"})
}
@@ -0,0 +1,36 @@
package amenity
import (
"api_yal/internal/logger"
"api_yal/internal/middleware"
"api_yal/internal/repository"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
l := logger.Get()
l.Debug("Регистрация маршрутов для amenity")
amenityRepo := repository.NewAmenityRepository(db)
amenityService := NewService(amenityRepo)
amenityHandler := NewHandler(amenityService)
r.Route("/amenities", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Get("/", amenityHandler.List)
r.Get("/{id}", amenityHandler.GetByID)
r.Get("/object/{objectId}", amenityHandler.GetByObject)
})
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtSecret))
r.Post("/", amenityHandler.Create)
r.Put("/{id}", amenityHandler.Update)
r.Delete("/{id}", amenityHandler.Delete)
r.Put("/object/{objectId}", amenityHandler.ReplaceObjectAmenities)
})
})
}
@@ -0,0 +1,125 @@
package amenity
import (
"api_yal/internal/models"
"api_yal/internal/repository"
"errors"
"gorm.io/gorm"
)
type AmenityService interface {
Create(req *CreateAmenityRequest) (*AmenityResponse, error)
GetByID(id uint) (*AmenityResponse, error)
Update(id uint, req *UpdateAmenityRequest) (*AmenityResponse, error)
Delete(id uint) error
List() ([]AmenityResponse, error)
ListByCategory(category string) ([]AmenityResponse, error)
GetByObject(objectID uint) ([]AmenityResponse, error)
ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error
}
type amenityServiceImpl struct {
repo repository.AmenityRepository
}
func NewService(repo repository.AmenityRepository) AmenityService {
return &amenityServiceImpl{repo: repo}
}
func (s *amenityServiceImpl) Create(req *CreateAmenityRequest) (*AmenityResponse, error) {
amenity := &models.Amenity{
Name: req.Name,
Category: req.Category,
Icon: req.Icon,
Description: req.Description,
}
if err := s.repo.Create(amenity); err != nil {
return nil, err
}
resp := ToAmenityResponse(amenity)
return &resp, nil
}
func (s *amenityServiceImpl) GetByID(id uint) (*AmenityResponse, error) {
amenity, err := s.repo.GetByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("amenity not found")
}
return nil, err
}
resp := ToAmenityResponse(amenity)
return &resp, nil
}
func (s *amenityServiceImpl) Update(id uint, req *UpdateAmenityRequest) (*AmenityResponse, error) {
amenity, err := s.repo.GetByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("amenity not found")
}
return nil, err
}
if req.Name != nil {
amenity.Name = *req.Name
}
if req.Category != nil {
amenity.Category = *req.Category
}
if req.Icon != nil {
amenity.Icon = *req.Icon
}
if req.Description != nil {
amenity.Description = *req.Description
}
if err := s.repo.Update(amenity); err != nil {
return nil, err
}
resp := ToAmenityResponse(amenity)
return &resp, nil
}
func (s *amenityServiceImpl) Delete(id uint) error {
return s.repo.Delete(id)
}
func (s *amenityServiceImpl) List() ([]AmenityResponse, error) {
amenities, err := s.repo.List()
if err != nil {
return nil, err
}
resp := make([]AmenityResponse, len(amenities))
for i, a := range amenities {
resp[i] = ToAmenityResponse(&a)
}
return resp, nil
}
func (s *amenityServiceImpl) ListByCategory(category string) ([]AmenityResponse, error) {
amenities, err := s.repo.ListByCategory(category)
if err != nil {
return nil, err
}
resp := make([]AmenityResponse, len(amenities))
for i, a := range amenities {
resp[i] = ToAmenityResponse(&a)
}
return resp, nil
}
func (s *amenityServiceImpl) GetByObject(objectID uint) ([]AmenityResponse, error) {
amenities, err := s.repo.GetByObject(objectID)
if err != nil {
return nil, err
}
resp := make([]AmenityResponse, len(amenities))
for i, a := range amenities {
resp[i] = ToAmenityResponse(&a)
}
return resp, nil
}
func (s *amenityServiceImpl) ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error {
return s.repo.ReplaceObjectAmenities(objectID, amenityIDs)
}
@@ -344,6 +344,33 @@ func (h *AuthHandler) MobileLogin(w http.ResponseWriter, r *http.Request) {
}) })
} }
// GetMe возвращает информацию о текущем пользователе
func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
account, err := h.authService.GetUserFromID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"user": UserInfo{
ID: account.Base.ID,
Email: account.Email,
FirstName: account.FirstName,
LastName: account.LastName,
FullName: account.FullName,
Role: account.Role,
},
})
}
// handleValidationError обрабатывает ошибки валидации // handleValidationError обрабатывает ошибки валидации
func (h *AuthHandler) handleValidationError(w http.ResponseWriter, err error) { func (h *AuthHandler) handleValidationError(w http.ResponseWriter, err error) {
var invalidValidationError *validator.InvalidValidationError var invalidValidationError *validator.InvalidValidationError
@@ -46,6 +46,7 @@ func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
r.Post("/logout", handler.Logout) r.Post("/logout", handler.Logout)
r.Post("/change-password", handler.RequestPasswordReset) r.Post("/change-password", handler.RequestPasswordReset)
r.Get("/me", handler.GetMe)
}) })
}) })
} }
@@ -23,6 +23,7 @@ type AuthService interface {
Logout(userID uint) error Logout(userID uint) error
ValidateAccessToken(tokenString string) (*jwt.MapClaims, error) ValidateAccessToken(tokenString string) (*jwt.MapClaims, error)
GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error) GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error)
GetUserFromID(userID uint) (*models.Account, error)
// Reset password methods // Reset password methods
RequestPasswordReset(email string) (string, error) // Возвращает reset token RequestPasswordReset(email string) (string, error) // Возвращает reset token
@@ -381,6 +382,11 @@ func (s *authServiceImpl) GetUserFromToken(claims *jwt.MapClaims) (*models.Accou
return s.accountRepo.GetByID(userID) return s.accountRepo.GetByID(userID)
} }
// GetUserFromID получает пользователя по ID
func (s *authServiceImpl) GetUserFromID(userID uint) (*models.Account, error) {
return s.accountRepo.GetByID(userID)
}
// generateAccessToken генерирует access token // generateAccessToken генерирует access token
func (s *authServiceImpl) generateAccessToken(account *models.Account) (string, time.Time, error) { func (s *authServiceImpl) generateAccessToken(account *models.Account) (string, time.Time, error) {
expiresAt := time.Now().Add(s.accessTokenTTL) expiresAt := time.Now().Add(s.accessTokenTTL)
@@ -12,9 +12,12 @@ import (
// CreateObjectRequest - DTO для создания объекта // CreateObjectRequest - DTO для создания объекта
type CreateObjectRequest struct { type CreateObjectRequest struct {
OwnerID uint `json:"owner_id" binding:"required"` OwnerID uint `json:"owner_id" binding:"required"`
Title string `json:"title"`
ShortName string `json:"short_name" binding:"required,min=1,max=255"` ShortName string `json:"short_name" binding:"required,min=1,max=255"`
LongName string `json:"long_name"` LongName string `json:"long_name"`
Type string `json:"type"` Type string `json:"type"`
Price float64 `json:"price"`
PricePeriod string `json:"price_period"`
Phone string `json:"phone"` Phone string `json:"phone"`
Email string `json:"email" binding:"omitempty,email"` Email string `json:"email" binding:"omitempty,email"`
Site string `json:"site" binding:"omitempty,url"` Site string `json:"site" binding:"omitempty,url"`
@@ -23,15 +26,20 @@ type CreateObjectRequest struct {
Address string `json:"address"` Address string `json:"address"`
Latitude float64 `json:"latitude" binding:"omitempty,latitude"` Latitude float64 `json:"latitude" binding:"omitempty,latitude"`
Longitude float64 `json:"longitude" binding:"omitempty,longitude"` Longitude float64 `json:"longitude" binding:"omitempty,longitude"`
IsActive *bool `json:"is_active"` // указатель, чтобы отличать false от отсутствия значения Status string `json:"status"`
IsActive *bool `json:"is_active"`
IsVerified *bool `json:"is_verified"` IsVerified *bool `json:"is_verified"`
AmenityIDs []uint `json:"amenity_ids"`
} }
// UpdateObjectRequest - DTO для обновления объекта (все поля опциональны) // UpdateObjectRequest - DTO для обновления объекта (все поля опциональны)
type UpdateObjectRequest struct { type UpdateObjectRequest struct {
Title *string `json:"title"`
ShortName *string `json:"short_name" binding:"omitempty,min=1,max=255"` ShortName *string `json:"short_name" binding:"omitempty,min=1,max=255"`
LongName *string `json:"long_name"` LongName *string `json:"long_name"`
Type *string `json:"type"` Type *string `json:"type"`
Price *float64 `json:"price"`
PricePeriod *string `json:"price_period"`
Phone *string `json:"phone"` Phone *string `json:"phone"`
Email *string `json:"email" binding:"omitempty,email"` Email *string `json:"email" binding:"omitempty,email"`
Site *string `json:"site" binding:"omitempty,url"` Site *string `json:"site" binding:"omitempty,url"`
@@ -40,8 +48,10 @@ type UpdateObjectRequest struct {
Address *string `json:"address"` Address *string `json:"address"`
Latitude *float64 `json:"latitude" binding:"omitempty,latitude"` Latitude *float64 `json:"latitude" binding:"omitempty,latitude"`
Longitude *float64 `json:"longitude" binding:"omitempty,longitude"` Longitude *float64 `json:"longitude" binding:"omitempty,longitude"`
Status *string `json:"status"`
IsActive *bool `json:"is_active"` IsActive *bool `json:"is_active"`
IsVerified *bool `json:"is_verified"` IsVerified *bool `json:"is_verified"`
AmenityIDs []uint `json:"amenity_ids"`
} }
// ObjectResponse - DTO для полного ответа с объектом (включая связанные данные) // ObjectResponse - DTO для полного ответа с объектом (включая связанные данные)
@@ -52,9 +62,12 @@ type ObjectResponse struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
OwnerID uint `json:"owner_id"` OwnerID uint `json:"owner_id"`
Owner *account.AccountResponse `json:"owner,omitempty"` Owner *account.AccountResponse `json:"owner,omitempty"`
Title string `json:"title"`
ShortName string `json:"short_name"` ShortName string `json:"short_name"`
LongName string `json:"long_name"` LongName string `json:"long_name"`
Type string `json:"type"` Type string `json:"type"`
Price float64 `json:"price"`
PricePeriod string `json:"price_period"`
Phone string `json:"phone"` Phone string `json:"phone"`
Email string `json:"email"` Email string `json:"email"`
Site string `json:"site"` Site string `json:"site"`
@@ -65,27 +78,53 @@ type ObjectResponse struct {
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
Status string `json:"status"`
ViewCount int `json:"view_count"`
FeedbackCount int `json:"feedback_count"` FeedbackCount int `json:"feedback_count"`
TouristRating *RatingResponse `json:"tourist_rating,omitempty"` TouristRating *RatingResponse `json:"tourist_rating,omitempty"`
EntrepreneurRating *RatingResponse `json:"entrepreneur_rating,omitempty"` EntrepreneurRating *RatingResponse `json:"entrepreneur_rating,omitempty"`
Feedbacks []FeedbackShortResponse `json:"feedbacks,omitempty"` Feedbacks []FeedbackShortResponse `json:"feedbacks,omitempty"`
Images []ImageResponse `json:"images,omitempty"`
Amenities []AmenityResponse `json:"amenities,omitempty"`
} }
// ObjectShortResponse - DTO для краткого ответа (списки, вложенные данные) // ObjectShortResponse - DTO для краткого ответа (списки, вложенные данные)
type ObjectShortResponse struct { type ObjectShortResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
Title string `json:"title"`
ShortName string `json:"short_name"` ShortName string `json:"short_name"`
LongName string `json:"long_name"` LongName string `json:"long_name"`
Type string `json:"type"` Type string `json:"type"`
Price float64 `json:"price"`
PricePeriod string `json:"price_period"`
Address string `json:"address"` Address string `json:"address"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
Status string `json:"status"`
FeedbackCount int `json:"feedback_count"` FeedbackCount int `json:"feedback_count"`
// Агрегированные рейтинги для списка // Агрегированные рейтинги для списка
TouristAverageScore float64 `json:"tourist_average_score,omitempty"` TouristAverageScore float64 `json:"tourist_average_score,omitempty"`
EntrepreneurAverageScore float64 `json:"entrepreneur_average_score,omitempty"` EntrepreneurAverageScore float64 `json:"entrepreneur_average_score,omitempty"`
} }
// ImageResponse - DTO для изображения
type ImageResponse struct {
ID uint `json:"id"`
ObjectID uint `json:"object_id"`
URL string `json:"url"`
IsPrimary bool `json:"is_primary"`
SortOrder int `json:"sort_order"`
}
// AmenityResponse - DTO для удобства
type AmenityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
}
// ObjectListResponse - DTO для списка объектов с пагинацией // ObjectListResponse - DTO для списка объектов с пагинацией
type ObjectListResponse struct { type ObjectListResponse struct {
Items []ObjectShortResponse `json:"items"` Items []ObjectShortResponse `json:"items"`
@@ -109,6 +109,7 @@ func (h *ObjectHandler) ListObjects(w http.ResponseWriter, r *http.Request) {
PageSize: h.getQueryParamInt(r, "page_size", 10), PageSize: h.getQueryParamInt(r, "page_size", 10),
Type: r.URL.Query().Get("type"), Type: r.URL.Query().Get("type"),
Query: r.URL.Query().Get("q"), Query: r.URL.Query().Get("q"),
ObjectStatus: r.URL.Query().Get("status"),
} }
if statusStr := r.URL.Query().Get("is_active"); statusStr != "" { if statusStr := r.URL.Query().Get("is_active"); statusStr != "" {
@@ -127,6 +128,26 @@ func (h *ObjectHandler) ListObjects(w http.ResponseWriter, r *http.Request) {
h.respondWithJSON(w, http.StatusOK, response) h.respondWithJSON(w, http.StatusOK, response)
} }
// GetMyObjects обрабатывает GET /objects/my
func (h *ObjectHandler) GetMyObjects(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
page := h.getQueryParamInt(r, "page", 1)
pageSize := h.getQueryParamInt(r, "page_size", 10)
response, err := h.objectService.GetObjectsByOwner(r.Context(), userID, page, pageSize)
if err != nil {
h.handleError(w, err)
return
}
h.respondWithJSON(w, http.StatusOK, response)
}
// GetObjectsByOwner обрабатывает GET /objects/owner/{ownerId} // GetObjectsByOwner обрабатывает GET /objects/owner/{ownerId}
func (h *ObjectHandler) GetObjectsByOwner(w http.ResponseWriter, r *http.Request) { func (h *ObjectHandler) GetObjectsByOwner(w http.ResponseWriter, r *http.Request) {
ownerID, err := strconv.ParseUint(chi.URLParam(r, "ownerId"), 10, 32) ownerID, err := strconv.ParseUint(chi.URLParam(r, "ownerId"), 10, 32)
@@ -31,6 +31,9 @@ func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtSecret)) r.Use(middleware.AuthMiddleware(jwtSecret))
// Мои объекты
r.Get("/objects/my", objectHandler.GetMyObjects)
// CRUD для объектов // CRUD для объектов
r.Post("/objects", objectHandler.CreateObject) r.Post("/objects", objectHandler.CreateObject)
r.Put("/objects/{id}", objectHandler.UpdateObject) r.Put("/objects/{id}", objectHandler.UpdateObject)
@@ -83,11 +83,23 @@ func (s *objectServiceImpl) CreateObject(ctx context.Context, req *CreateObjectR
isVerified = *req.IsVerified isVerified = *req.IsVerified
} }
title := req.Title
if title == "" {
title = req.ShortName
}
status := models.ObjectStatusActive
if req.Status != "" {
status = models.ObjectStatus(req.Status)
}
object := &models.Object{ object := &models.Object{
OwnerID: req.OwnerID, OwnerID: req.OwnerID,
Title: title,
ShortName: req.ShortName, ShortName: req.ShortName,
LongName: req.LongName, LongName: req.LongName,
Type: req.Type, Type: req.Type,
Price: req.Price,
PricePeriod: req.PricePeriod,
Phone: req.Phone, Phone: req.Phone,
Email: req.Email, Email: req.Email,
Site: req.Site, Site: req.Site,
@@ -98,6 +110,8 @@ func (s *objectServiceImpl) CreateObject(ctx context.Context, req *CreateObjectR
Longitude: req.Longitude, Longitude: req.Longitude,
IsActive: isActive, IsActive: isActive,
IsVerified: isVerified, IsVerified: isVerified,
Status: status,
ViewCount: 0,
FeedbackCount: 0, FeedbackCount: 0,
} }
@@ -173,6 +187,11 @@ func (s *objectServiceImpl) ListObjects(ctx context.Context, req *ListObjectsReq
// Применяем фильтры // Применяем фильтры
switch { switch {
case req.ObjectStatus != "":
objects, err = s.objectRepository.ListByObjectStatus(req.ObjectStatus, offset, pageSize)
if err == nil {
total, _ = s.countObjectsByStatusString(req.ObjectStatus)
}
case req.Type != "": case req.Type != "":
objects, err = s.objectRepository.ListByType(req.Type, offset, pageSize) objects, err = s.objectRepository.ListByType(req.Type, offset, pageSize)
if err == nil { if err == nil {
@@ -553,6 +572,9 @@ func (s *objectServiceImpl) validateCreateRequest(req *CreateObjectRequest) erro
} }
func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjectRequest) { func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjectRequest) {
if req.Title != nil {
object.Title = *req.Title
}
if req.ShortName != nil { if req.ShortName != nil {
object.ShortName = *req.ShortName object.ShortName = *req.ShortName
} }
@@ -562,6 +584,12 @@ func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjec
if req.Type != nil { if req.Type != nil {
object.Type = *req.Type object.Type = *req.Type
} }
if req.Price != nil {
object.Price = *req.Price
}
if req.PricePeriod != nil {
object.PricePeriod = *req.PricePeriod
}
if req.Phone != nil { if req.Phone != nil {
object.Phone = *req.Phone object.Phone = *req.Phone
} }
@@ -586,6 +614,9 @@ func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjec
if req.Longitude != nil { if req.Longitude != nil {
object.Longitude = *req.Longitude object.Longitude = *req.Longitude
} }
if req.Status != nil {
object.Status = models.ObjectStatus(*req.Status)
}
if req.IsActive != nil { if req.IsActive != nil {
object.IsActive = *req.IsActive object.IsActive = *req.IsActive
} }
@@ -610,9 +641,12 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
CreatedAt: object.CreatedAt, CreatedAt: object.CreatedAt,
UpdatedAt: object.UpdatedAt, UpdatedAt: object.UpdatedAt,
OwnerID: object.OwnerID, OwnerID: object.OwnerID,
Title: object.Title,
ShortName: object.ShortName, ShortName: object.ShortName,
LongName: object.LongName, LongName: object.LongName,
Type: object.Type, Type: object.Type,
Price: object.Price,
PricePeriod: object.PricePeriod,
Phone: object.Phone, Phone: object.Phone,
Email: object.Email, Email: object.Email,
Site: object.Site, Site: object.Site,
@@ -623,6 +657,8 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
Longitude: object.Longitude, Longitude: object.Longitude,
IsActive: object.IsActive, IsActive: object.IsActive,
IsVerified: object.IsVerified, IsVerified: object.IsVerified,
Status: string(object.Status),
ViewCount: object.ViewCount,
FeedbackCount: object.FeedbackCount, FeedbackCount: object.FeedbackCount,
} }
@@ -661,18 +697,48 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
} }
} }
if len(object.Images) > 0 {
resp.Images = make([]ImageResponse, len(object.Images))
for i, img := range object.Images {
resp.Images[i] = ImageResponse{
ID: img.ID,
ObjectID: img.ObjectID,
URL: img.URL,
IsPrimary: img.IsPrimary,
SortOrder: img.SortOrder,
}
}
}
if len(object.Amenities) > 0 {
resp.Amenities = make([]AmenityResponse, len(object.Amenities))
for i, a := range object.Amenities {
resp.Amenities[i] = AmenityResponse{
ID: a.ID,
Name: a.Name,
Category: a.Category,
Icon: a.Icon,
Description: a.Description,
}
}
}
return resp return resp
} }
func (s *objectServiceImpl) mapToObjectShortResponse(object *models.Object) ObjectShortResponse { func (s *objectServiceImpl) mapToObjectShortResponse(object *models.Object) ObjectShortResponse {
return ObjectShortResponse{ return ObjectShortResponse{
ID: object.ID, ID: object.ID,
Title: object.Title,
ShortName: object.ShortName, ShortName: object.ShortName,
LongName: object.LongName, LongName: object.LongName,
Type: object.Type, Type: object.Type,
Price: object.Price,
PricePeriod: object.PricePeriod,
Address: object.Address, Address: object.Address,
IsActive: object.IsActive, IsActive: object.IsActive,
IsVerified: object.IsVerified, IsVerified: object.IsVerified,
Status: string(object.Status),
FeedbackCount: object.FeedbackCount, FeedbackCount: object.FeedbackCount,
} }
} }
@@ -729,3 +795,7 @@ func (s *objectServiceImpl) countObjectsBySearch(query string) (int64, error) {
// TODO: Добавить метод CountBySearch в репозиторий // TODO: Добавить метод CountBySearch в репозиторий
return 0, nil return 0, nil
} }
func (s *objectServiceImpl) countObjectsByStatusString(status string) (int64, error) {
return 0, nil
}
@@ -6,6 +6,7 @@ type ListObjectsRequest struct {
PageSize int PageSize int
Type string Type string
Status *bool Status *bool
ObjectStatus string
Query string Query string
} }
@@ -0,0 +1,150 @@
package upload
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"api_yal/internal/logger"
"api_yal/internal/middleware"
"api_yal/internal/models"
"go.uber.org/zap"
"gorm.io/gorm"
)
type UploadHandler struct {
db *gorm.DB
uploadPath string
}
func NewHandler(db *gorm.DB, uploadPath string) *UploadHandler {
if err := os.MkdirAll(uploadPath, 0755); err != nil {
logger.Get().Warn("failed to create upload directory", zap.String("path", uploadPath), zap.Error(err))
}
return &UploadHandler{db: db, uploadPath: uploadPath}
}
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "File is required", http.StatusBadRequest)
return
}
defer file.Close()
objectIDStr := r.FormValue("object_id")
if objectIDStr == "" {
http.Error(w, "object_id is required", http.StatusBadRequest)
return
}
var objectID uint
if _, err := fmt.Sscan(objectIDStr, &objectID); err != nil {
http.Error(w, "Invalid object_id", http.StatusBadRequest)
return
}
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext)
filePath := filepath.Join(h.uploadPath, filename)
dst, err := os.Create(filePath)
if err != nil {
logger.Get().Error("failed to create file", zap.Error(err))
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
logger.Get().Error("failed to write file", zap.Error(err))
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
isPrimary := false
if r.FormValue("is_primary") == "true" {
isPrimary = true
}
image := models.ObjectImage{
ObjectID: objectID,
URL: "/uploads/" + filename,
IsPrimary: isPrimary,
}
if err := h.db.Create(&image).Error; err != nil {
logger.Get().Error("failed to save image record", zap.Error(err))
http.Error(w, "Failed to save image", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": image.ID,
"url": image.URL,
"object_id": image.ObjectID,
"is_primary": image.IsPrimary,
})
}
func (h *UploadHandler) DeleteImage(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
imageIDStr := r.URL.Query().Get("id")
if imageIDStr == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
var imageID uint
if _, err := fmt.Sscan(imageIDStr, &imageID); err != nil {
http.Error(w, "Invalid id", http.StatusBadRequest)
return
}
var image models.ObjectImage
if err := h.db.First(&image, imageID).Error; err != nil {
http.Error(w, "Image not found", http.StatusNotFound)
return
}
var object models.Object
if err := h.db.First(&object, image.ObjectID).Error; err != nil {
http.Error(w, "Object not found", http.StatusNotFound)
return
}
if object.OwnerID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
filePath := filepath.Join(h.uploadPath, filepath.Base(image.URL))
os.Remove(filePath)
h.db.Delete(&image)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Image deleted"})
}
@@ -0,0 +1,23 @@
package upload
import (
"api_yal/internal/logger"
"api_yal/internal/middleware"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string, uploadPath string) {
l := logger.Get()
l.Debug("Регистрация маршрутов для upload")
handler := NewHandler(db, uploadPath)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtSecret))
r.Post("/upload", handler.Upload)
r.Delete("/upload", handler.DeleteImage)
})
}
@@ -2,6 +2,16 @@ package models
import () import ()
type ObjectStatus string
const (
ObjectStatusDraft ObjectStatus = "draft"
ObjectStatusModeration ObjectStatus = "moderation"
ObjectStatusActive ObjectStatus = "active"
ObjectStatusInactive ObjectStatus = "inactive"
ObjectStatusRejected ObjectStatus = "rejected"
)
type Object struct { type Object struct {
/*ID, CreatedAt, UpdatedAt, DeletedAt (Update's history)*/ /*ID, CreatedAt, UpdatedAt, DeletedAt (Update's history)*/
Base `gorm:"embedded"` Base `gorm:"embedded"`
@@ -11,13 +21,15 @@ type Object struct {
Owner Account `gorm:"foreignKey:OwnerID;references:ID" json:"owner"` Owner Account `gorm:"foreignKey:OwnerID;references:ID" json:"owner"`
// Основная информация // Основная информация
// короткое название Title string `gorm:"default:''" json:"title"`
ShortName string `gorm:"not null" json:"short_name"` ShortName string `gorm:"not null" json:"short_name"`
// длинное название
LongName string `json:"long_name"` LongName string `json:"long_name"`
// тип места отдыха
Type string `json:"type"` Type string `json:"type"`
// Цена
Price float64 `gorm:"default:0" json:"price"`
PricePeriod string `gorm:"default:'per_unit'" json:"price_period"`
// контактные данные // контактные данные
Phone string `json:"phone"` Phone string `json:"phone"`
Email string `json:"email"` Email string `json:"email"`
@@ -37,6 +49,8 @@ type Object struct {
// Статус объекта // Статус объекта
IsActive bool `gorm:"default:true;index" json:"is_active"` IsActive bool `gorm:"default:true;index" json:"is_active"`
IsVerified bool `gorm:"default:false" json:"is_verified"` IsVerified bool `gorm:"default:false" json:"is_verified"`
Status ObjectStatus `gorm:"default:active" json:"status"`
ViewCount int `gorm:"default:0" json:"view_count"`
// Связи с рейтингами (для разных платформ) // Связи с рейтингами (для разных платформ)
TouristRating *Rating `gorm:"foreignKey:ObjectID;references:ID;where:platform='tourist'" json:"tourist_rating,omitempty"` TouristRating *Rating `gorm:"foreignKey:ObjectID;references:ID;where:platform='tourist'" json:"tourist_rating,omitempty"`
@@ -48,4 +62,26 @@ type Object struct {
// Связи с отзывами // Связи с отзывами
Feedbacks []Feedback `gorm:"foreignKey:ObjectID" json:"feedbacks,omitempty"` Feedbacks []Feedback `gorm:"foreignKey:ObjectID" json:"feedbacks,omitempty"`
FeedbackCount int `gorm:"default:0" json:"feedback_count"` FeedbackCount int `gorm:"default:0" json:"feedback_count"`
// Изображения
Images []ObjectImage `gorm:"foreignKey:ObjectID" json:"images,omitempty"`
// Удобства (many-to-many)
Amenities []Amenity `gorm:"many2many:object_amenities;" json:"amenities,omitempty"`
}
type ObjectImage struct {
Base `gorm:"embedded"`
ObjectID uint `gorm:"not null;index" json:"object_id"`
URL string `gorm:"not null" json:"url"`
IsPrimary bool `gorm:"default:false" json:"is_primary"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
type Amenity struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;not null" json:"name"`
Category string `json:"category"`
Icon string `json:"icon"`
Description string `json:"description"`
} }
@@ -0,0 +1,18 @@
package repository
import (
"api_yal/internal/models"
)
type AmenityRepository interface {
Create(amenity *models.Amenity) error
GetByID(id uint) (*models.Amenity, error)
Update(amenity *models.Amenity) error
Delete(id uint) error
List() ([]models.Amenity, error)
ListByCategory(category string) ([]models.Amenity, error)
GetByObject(objectID uint) ([]models.Amenity, error)
AttachToObject(objectID uint, amenityIDs []uint) error
DetachFromObject(objectID uint, amenityIDs []uint) error
ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error
}
@@ -0,0 +1,100 @@
package repository
import (
"api_yal/internal/models"
"gorm.io/gorm"
)
type amenityRepositoryImpl struct {
db *gorm.DB
}
func NewAmenityRepository(db *gorm.DB) AmenityRepository {
return &amenityRepositoryImpl{db: db}
}
func (r *amenityRepositoryImpl) Create(amenity *models.Amenity) error {
return r.db.Create(amenity).Error
}
func (r *amenityRepositoryImpl) GetByID(id uint) (*models.Amenity, error) {
var amenity models.Amenity
err := r.db.First(&amenity, id).Error
if err != nil {
return nil, err
}
return &amenity, nil
}
func (r *amenityRepositoryImpl) Update(amenity *models.Amenity) error {
return r.db.Save(amenity).Error
}
func (r *amenityRepositoryImpl) Delete(id uint) error {
return r.db.Delete(&models.Amenity{}, id).Error
}
func (r *amenityRepositoryImpl) List() ([]models.Amenity, error) {
var amenities []models.Amenity
err := r.db.Find(&amenities).Error
if err != nil {
return nil, err
}
return amenities, nil
}
func (r *amenityRepositoryImpl) ListByCategory(category string) ([]models.Amenity, error) {
var amenities []models.Amenity
err := r.db.Where("category = ?", category).Find(&amenities).Error
if err != nil {
return nil, err
}
return amenities, nil
}
func (r *amenityRepositoryImpl) GetByObject(objectID uint) ([]models.Amenity, error) {
var object models.Object
err := r.db.Preload("Amenities").First(&object, objectID).Error
if err != nil {
return nil, err
}
return object.Amenities, nil
}
func (r *amenityRepositoryImpl) AttachToObject(objectID uint, amenityIDs []uint) error {
var object models.Object
if err := r.db.First(&object, objectID).Error; err != nil {
return err
}
var amenities []models.Amenity
if err := r.db.Find(&amenities, amenityIDs).Error; err != nil {
return err
}
return r.db.Model(&object).Association("Amenities").Append(&amenities)
}
func (r *amenityRepositoryImpl) DetachFromObject(objectID uint, amenityIDs []uint) error {
var object models.Object
if err := r.db.First(&object, objectID).Error; err != nil {
return err
}
var amenities []models.Amenity
if err := r.db.Find(&amenities, amenityIDs).Error; err != nil {
return err
}
return r.db.Model(&object).Association("Amenities").Delete(&amenities)
}
func (r *amenityRepositoryImpl) ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error {
var object models.Object
if err := r.db.First(&object, objectID).Error; err != nil {
return err
}
var amenities []models.Amenity
if len(amenityIDs) > 0 {
if err := r.db.Find(&amenities, amenityIDs).Error; err != nil {
return err
}
}
return r.db.Model(&object).Association("Amenities").Replace(&amenities)
}
@@ -34,6 +34,9 @@ type ObjectRepository interface {
// ListByStatus возвращает объекты по статусу // ListByStatus возвращает объекты по статусу
ListByStatus(isActive bool, offset, limit int) ([]models.Object, error) ListByStatus(isActive bool, offset, limit int) ([]models.Object, error)
// ListByObjectStatus возвращает объекты по статусу объекта (draft, active, etc.)
ListByObjectStatus(status string, offset, limit int) ([]models.Object, error)
// Search находит объекты по названию, типу или адресу // Search находит объекты по названию, типу или адресу
Search(query string, offset, limit int) ([]models.Object, error) Search(query string, offset, limit int) ([]models.Object, error)
@@ -23,7 +23,7 @@ func (r *objectRepositoryImpl) Create(object *models.Object) error {
// GetByID возвращает объект по ID // GetByID возвращает объект по ID
func (r *objectRepositoryImpl) GetByID(id uint) (*models.Object, error) { func (r *objectRepositoryImpl) GetByID(id uint) (*models.Object, error) {
var object models.Object var object models.Object
err := r.db.Preload("Owner").Preload("TouristRating").Preload("EntrepreneurRating").Preload("Ratings").Preload("Feedbacks").First(&object, id).Error err := r.db.Preload("Owner").Preload("TouristRating").Preload("EntrepreneurRating").Preload("Ratings").Preload("Feedbacks").Preload("Images").Preload("Amenities").First(&object, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -43,7 +43,7 @@ func (r *objectRepositoryImpl) Delete(id uint) error {
// List возвращает список объектов с пагинацией // List возвращает список объектов с пагинацией
func (r *objectRepositoryImpl) List(offset, limit int) ([]models.Object, error) { func (r *objectRepositoryImpl) List(offset, limit int) ([]models.Object, error) {
var objects []models.Object var objects []models.Object
err := r.db.Preload("Owner").Offset(offset).Limit(limit).Find(&objects).Error err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Offset(offset).Limit(limit).Find(&objects).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -60,7 +60,7 @@ func (r *objectRepositoryImpl) Count() (int64, error) {
// ListByOwner возвращает объекты по владельцу // ListByOwner возвращает объекты по владельцу
func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]models.Object, error) { func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]models.Object, error) {
var objects []models.Object var objects []models.Object
err := r.db.Preload("Owner").Where("owner_id = ?", ownerID).Offset(offset).Limit(limit).Find(&objects).Error err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("owner_id = ?", ownerID).Offset(offset).Limit(limit).Find(&objects).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -70,7 +70,7 @@ func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]m
// ListByType возвращает объекты по типу // ListByType возвращает объекты по типу
func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int) ([]models.Object, error) { func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int) ([]models.Object, error) {
var objects []models.Object var objects []models.Object
err := r.db.Preload("Owner").Where("type = ?", objectType).Offset(offset).Limit(limit).Find(&objects).Error err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("type = ?", objectType).Offset(offset).Limit(limit).Find(&objects).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -80,7 +80,17 @@ func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int)
// ListByStatus возвращает объекты по статусу // ListByStatus возвращает объекты по статусу
func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([]models.Object, error) { func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([]models.Object, error) {
var objects []models.Object var objects []models.Object
err := r.db.Preload("Owner").Where("is_active = ?", isActive).Offset(offset).Limit(limit).Find(&objects).Error err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("is_active = ?", isActive).Offset(offset).Limit(limit).Find(&objects).Error
if err != nil {
return nil, err
}
return objects, nil
}
// ListByObjectStatus возвращает объекты по статусу объекта (draft, active, etc.)
func (r *objectRepositoryImpl) ListByObjectStatus(status string, offset, limit int) ([]models.Object, error) {
var objects []models.Object
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("status = ?", status).Offset(offset).Limit(limit).Find(&objects).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -90,7 +100,7 @@ func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([
// Search находит объекты по названию, типу или адресу // Search находит объекты по названию, типу или адресу
func (r *objectRepositoryImpl) Search(query string, offset, limit int) ([]models.Object, error) { func (r *objectRepositoryImpl) Search(query string, offset, limit int) ([]models.Object, error) {
var objects []models.Object var objects []models.Object
err := r.db.Preload("Owner").Where("short_name LIKE ? OR long_name LIKE ? OR type LIKE ? OR address LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%").Offset(offset).Limit(limit).Find(&objects).Error err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("short_name LIKE ? OR long_name LIKE ? OR type LIKE ? OR address LIKE ? OR title LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%").Offset(offset).Limit(limit).Find(&objects).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -3,12 +3,14 @@ package router
import ( import (
"api_yal/internal/config" "api_yal/internal/config"
"api_yal/internal/domain/account" "api_yal/internal/domain/account"
"api_yal/internal/domain/amenity"
"api_yal/internal/domain/appeal" "api_yal/internal/domain/appeal"
"api_yal/internal/domain/auth" "api_yal/internal/domain/auth"
"api_yal/internal/domain/comment" "api_yal/internal/domain/comment"
"api_yal/internal/domain/feetback" "api_yal/internal/domain/feetback"
"api_yal/internal/domain/object" "api_yal/internal/domain/object"
"api_yal/internal/domain/rating" "api_yal/internal/domain/rating"
"api_yal/internal/domain/upload"
"api_yal/internal/logger" "api_yal/internal/logger"
"time" "time"
@@ -74,6 +76,12 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
// Регистрируем маршруты обращений // Регистрируем маршруты обращений
appeal.RegisterRoutes(r, db, config.JWTSecret) appeal.RegisterRoutes(r, db, config.JWTSecret)
// Регистрируем маршруты для удобств
amenity.RegisterRoutes(r, db, config.JWTSecret)
// Регистрируем маршруты для загрузки файлов
upload.RegisterRoutes(r, db, config.JWTSecret, config.UploadPath)
}) })
zapLogger.Info("Настройка маршрутов завершена") zapLogger.Info("Настройка маршрутов завершена")
@@ -106,9 +114,6 @@ func addProductionMiddleware(r *chi.Mux, config *config.Config) {
MaxAge: 300, MaxAge: 300,
})) }))
// Content-Type проверка
r.Use(ChiMiddleware.AllowContentType("application/json", "application/xml"))
// Rate limiting // Rate limiting
if config.RateLimit.Enabled { if config.RateLimit.Enabled {
r.Use(ChiMiddleware.Throttle(config.RateLimit.RequestsPerSecond)) r.Use(ChiMiddleware.Throttle(config.RateLimit.RequestsPerSecond))
@@ -115,6 +115,20 @@ func (m *MockObjectRepository) ListByStatus(isActive bool, offset, limit int) ([
return result[start:end], nil return result[start:end], nil
} }
func (m *MockObjectRepository) ListByObjectStatus(status string, offset, limit int) ([]models.Object, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []models.Object
for _, obj := range m.objects {
if string(obj.Status) == status {
result = append(result, *obj)
}
}
start := min(offset, len(result))
end := min(start+limit, len(result))
return result[start:end], nil
}
func (m *MockObjectRepository) Search(query string, offset, limit int) ([]models.Object, error) { func (m *MockObjectRepository) Search(query string, offset, limit int) ([]models.Object, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
+2 -2
View File
@@ -42,7 +42,7 @@
1. **easysite** – Nuxt.js приложение (easysite102.ru) 1. **easysite** – Nuxt.js приложение (easysite102.ru)
2. **yalarba** Vue.js SPA приложение (yalarba.ru) 2. **yalarba** Vue.js SPA приложение (yalarba.ru)
3. **api_tp** REST API для YalArba (Go) 3. **api_tp** REST API для YalArba (Go)
4. **api_es** REST API для EasySite (Go) 4. **api_yal** REST API для EasySite (Go)
5. **api_bb** REST API для "Бегущий Башкир" (Go) 5. **api_bb** REST API для "Бегущий Башкир" (Go)
6. **db, db_bb** PostgreSQL базы данных 6. **db, db_bb** PostgreSQL базы данных
7. **nginx** – Веб-сервер с reverse proxy и SSL 7. **nginx** – Веб-сервер с reverse proxy и SSL
@@ -55,7 +55,7 @@
``` ```
Турист (yalarba.ru) → API_TP → БД (поиск, отзывы, маршруты) Турист (yalarba.ru) → API_TP → БД (поиск, отзывы, маршруты)
Владелец (easysite102.ru) → API_ES → БД (добавление объектов, управление) Владелец (easysite102.ru) → api_yal → БД (добавление объектов, управление)
Администратор → Nginx + аналитика (мониторинг, логи) Администратор → Nginx + аналитика (мониторинг, логи)
``` ```
@@ -1,79 +1,87 @@
<!-- components/ObjectCard.vue -->
<template> <template>
<div class="card cursor-pointer" @click="$emit('click')"> <div class="card cursor-pointer" @click="$emit('click')">
<div class="relative"> <div class="relative">
<img <img
:src="object.image" :src="imageSrc"
:alt="object.title" :alt="object.title"
class="w-full h-48 object-cover" class="w-full h-48 object-cover"
> >
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<span class="badge badge-primary"> <span class="badge" :class="statusBadgeClass">
{{ getTypeLabel(object.type) }} {{ statusLabel }}
</span> </span>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<h3 class="text-lg font-semibold mb-2">{{ object.title }}</h3> <h3 class="text-lg font-semibold mb-2">{{ object.title || object.short_name }}</h3>
<p class="text-gray-600 text-sm mb-3 line-clamp-2"> <p class="text-gray-600 text-sm mb-3 line-clamp-2">
{{ object.description }} {{ object.address || 'Адрес не указан' }}
</p> </p>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span class="text-yellow-500"></span> <span class="text-yellow-500"></span>
<span class="text-sm font-medium">{{ object.rating }}</span> <span class="text-sm font-medium">{{ averageScore }}</span>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="font-bold text-primary-600"> <div class="font-bold text-primary-600">
{{ formatPrice(object.price) }} {{ formatPrice(object.price) }}
</div> </div>
<div class="text-xs text-gray-500">за ночь</div> <div class="text-xs text-gray-500">{{ object.price_period || 'за единицу' }}</div>
</div> </div>
</div> </div>
<div class="mt-3 flex items-center text-sm text-gray-500"> <div class="mt-3 flex items-center text-sm text-gray-500">
<span class="mr-2">📍</span> <span class="mr-2">📍</span>
<span>{{ object.city }}</span> <span>{{ object.address || 'Адрес не указан' }}</span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface ObjectItem { import type { ObjectShortResponse } from '~/types/objects'
id: number
title: string
type: string
city: string
price: number
rating: number
image: string
description: string
}
interface Props { interface Props {
object: ObjectItem object: ObjectShortResponse
} }
defineProps<Props>() const props = defineProps<Props>()
defineEmits<{ defineEmits<{ click: [] }>()
click: []
}>()
const getTypeLabel = (type: string) => { const imageSrc = computed(() => {
const types: Record<string, string> = { return '/images/placeholder.jpg'
hotel: 'Отель', })
sanatorium: 'Санаторий',
guest_house: 'Гостевой дом',
tour: 'Тур',
excursion: 'Экскурсия'
}
return types[type] || type
}
const formatPrice = (price: number) => { const averageScore = computed(() => {
return props.object.tourist_average_score || props.object.entrepreneur_average_score || '—'
})
const statusLabel = computed(() => {
const labels: Record<string, string> = {
active: 'Активен',
draft: 'Черновик',
moderation: 'На модерации',
inactive: 'Неактивен',
rejected: 'Отклонён'
}
return labels[props.object.status] || props.object.status
})
const statusBadgeClass = computed(() => {
const classes: Record<string, string> = {
active: 'badge-success',
draft: 'badge-secondary',
moderation: 'badge-warning',
inactive: 'badge-secondary',
rejected: 'badge-error'
}
return classes[props.object.status] || 'badge-secondary'
})
const formatPrice = (price: number | undefined) => {
if (!price && price !== 0) return '—'
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', style: 'currency',
currency: 'RUB', currency: 'RUB',
@@ -1,7 +1,5 @@
<!-- components/ObjectForm.vue -->
<template> <template>
<form class="space-y-6" @submit.prevent="handleSubmit"> <form class="space-y-6" @submit.prevent="handleSubmit">
<!-- Основная информация -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="text-lg font-semibold">Основная информация</h3> <h3 class="text-lg font-semibold">Основная информация</h3>
@@ -11,12 +9,22 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Название объекта *</label> <label class="form-label">Название объекта *</label>
<input <input
v-model="formData.title" v-model="formData.short_name"
type="text" type="text"
class="form-input" class="form-input"
required required
placeholder="Введите название"> placeholder="Короткое название">
</div> </div>
<div class="form-group">
<label class="form-label">Полное название</label>
<input
v-model="formData.long_name"
type="text"
class="form-input"
placeholder="Полное название (если отличается)">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group">
<label class="form-label">Тип объекта *</label> <label class="form-label">Тип объекта *</label>
<select v-model="formData.type" class="form-select" required> <select v-model="formData.type" class="form-select" required>
@@ -29,91 +37,95 @@
<option value="restaurant">Ресторан</option> <option value="restaurant">Ресторан</option>
</select> </select>
</div> </div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Описание *</label> <label class="form-label">Статус</label>
<select v-model="formData.status" class="form-select">
<option value="draft">Черновик</option>
<option value="moderation">Отправить на модерацию</option>
<option value="active">Активен</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Описание</label>
<textarea <textarea
v-model="formData.description" class="form-input" rows="4" required v-model="formData.description" class="form-input" rows="4"
placeholder="Подробное описание объекта"/> placeholder="Подробное описание объекта"/>
</div> </div>
<div class="form-group">
<label class="form-label">Краткое описание</label>
<textarea
v-model="formData.short_description" class="form-input" rows="2"
placeholder="Краткое описание для списка"/>
</div>
</div> </div>
</div> </div>
<!-- Местоположение -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="text-lg font-semibold">Местоположение</h3> <h3 class="text-lg font-semibold">Местоположение</h3>
</div> </div>
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group">
<label class="form-label">Город *</label> <label class="form-label">Адрес</label>
<input v-model="formData.city" type="text" class="form-input" required placeholder="Город">
</div>
<div class="form-group">
<label class="form-label">Адрес *</label>
<input <input
v-model="formData.address" type="text" class="form-input" required v-model="formData.address" type="text" class="form-input"
placeholder="Полный адрес"> placeholder="Полный адрес">
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Цены и контакты -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="text-lg font-semibold">Цены и контакты</h3> <h3 class="text-lg font-semibold">Цены и контакты</h3>
</div> </div>
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group">
<label class="form-label">Цена</label>
<input v-model="formData.price" type="number" class="form-input" placeholder="0">
</div>
<div class="form-group">
<label class="form-label">Период цены</label>
<select v-model="formData.price_period" class="form-select">
<option value="">Не указано</option>
<option value="per_night">За ночь</option>
<option value="per_person">За человека</option>
<option value="per_tour">За тур</option>
<option value="per_hour">За час</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Сайт</label>
<input
v-model="formData.site" type="url" class="form-input"
placeholder="https://">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group">
<label class="form-label">Цена за ночь/услугу *</label> <label class="form-label">Телефон</label>
<input v-model="formData.price" type="number" class="form-input" required placeholder="0">
</div>
<div class="form-group">
<label class="form-label">Телефон *</label>
<input <input
v-model="formData.contact.phone" type="tel" class="form-input" required v-model="formData.phone" type="tel" class="form-input"
placeholder="+7 (XXX) XXX-XX-XX"> placeholder="+7 (XXX) XXX-XX-XX">
</div> </div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Email</label> <label class="form-label">Email</label>
<input <input
v-model="formData.contact.email" type="email" class="form-input" v-model="formData.email" type="email" class="form-input"
placeholder="email@example.com"> placeholder="email@example.com">
</div> </div>
</div> </div>
</div> </div>
<!-- Удобства -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold">Удобства и услуги</h3>
</div>
<div class="card-body">
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<label v-for="amenity in availableAmenities" :key="amenity" class="flex items-center space-x-2">
<input
v-model="formData.amenities" type="checkbox" :value="amenity"
class="rounded border-gray-300">
<span class="text-sm">{{ amenity }}</span>
</label>
</div>
</div>
</div> </div>
<!-- Кнопки действий -->
<div class="flex gap-4 justify-end"> <div class="flex gap-4 justify-end">
<button type="button" class="btn btn-outline" :disabled="loading" @click="$emit('cancel')"> <button type="button" class="btn btn-outline" :disabled="loading" @click="$emit('cancel')">
Отмена Отмена
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="loading"> <button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading">Сохранение...</span> <span v-if="loading">Сохранение...</span>
<span v-else>{{ props.object ? 'Обновить' : 'Создать' }}</span> <span v-else>{{ object ? 'Обновить' : 'Создать' }}</span>
</button> </button>
</div> </div>
</form> </form>
@@ -121,18 +133,18 @@ v-model="formData.amenities" type="checkbox" :value="amenity"
<script setup lang="ts"> <script setup lang="ts">
interface ObjectFormData { interface ObjectFormData {
title: string short_name: string
long_name: string
type: string type: string
description: string description: string
city: string short_description: string
address: string address: string
price: number price: number | null
images: string[] price_period: string
amenities: string[]
contact: {
phone: string phone: string
email: string email: string
} site: string
status: string
} }
interface Props { interface Props {
@@ -150,27 +162,21 @@ const emit = defineEmits<{
cancel: [] cancel: []
}>() }>()
const availableAmenities = [
'Wi-Fi', 'Парковка', 'Бассейн', 'СПА', 'Завтрак',
'Кондиционер', 'Трансфер', 'Экскурсии', 'Баня', 'Ресторан'
]
const formData = reactive<ObjectFormData>({ const formData = reactive<ObjectFormData>({
title: '', short_name: '',
long_name: '',
type: '', type: '',
description: '', description: '',
city: '', short_description: '',
address: '', address: '',
price: 0, price: null,
images: [], price_period: '',
amenities: [],
contact: {
phone: '', phone: '',
email: '' email: '',
} site: '',
status: 'draft'
}) })
// Заполнение формы данными при редактировании
watch(() => props.object, (newObject) => { watch(() => props.object, (newObject) => {
if (newObject) { if (newObject) {
Object.assign(formData, newObject) Object.assign(formData, newObject)
@@ -178,6 +184,11 @@ watch(() => props.object, (newObject) => {
}, { immediate: true }) }, { immediate: true })
const handleSubmit = () => { const handleSubmit = () => {
emit('submit', { ...formData }) const data = { ...formData }
if (data.price === null) {
delete (data as Record<string, unknown>).price
}
if (!data.price_period) delete (data as Record<string, unknown>).price_period
emit('submit', data)
} }
</script> </script>
@@ -1,28 +1,25 @@
// composables/useAuth.ts import type { UserInfo, LoginForm, RegisterForm, AuthResponse } from '~/types/auth'
import type { User, LoginForm, RegisterForm } from '~/types/auth'
export const useAuth = () => { export const useAuth = () => {
const user = useState<User | null>('user', () => null) const config = useRuntimeConfig()
const apiBase = config.public.apiBase
const user = useState<UserInfo | null>('user', () => null)
const isAuthenticated = computed(() => !!user.value) const isAuthenticated = computed(() => !!user.value)
const loading = ref(false) const loading = ref(false)
const login = async (credentials: LoginForm) => { const login = async (credentials: LoginForm) => {
loading.value = true loading.value = true
try { try {
const response = await $fetch<{ user: User; token: string }>( const response = await $fetch<AuthResponse>(`${apiBase}/auth/login`, {
'https://easysite102.ru/api/auth/login',
{
method: 'POST', method: 'POST',
body: credentials body: credentials
} })
)
user.value = response.user user.value = response.user
// Сохраняем токен в localStorage или cookies
localStorage.setItem('auth_token', response.token) localStorage.setItem('auth_token', response.token)
return response return response
// eslint-disable-next-line no-useless-catch
} catch (error) { } catch (error) {
throw error throw error
} finally { } finally {
@@ -34,21 +31,16 @@ export const useAuth = () => {
loading.value = true loading.value = true
try { try {
const { passwordConfirm, ...registerData } = userData const { passwordConfirm, ...registerData } = userData
const full_name = `${userData.first_name} ${userData.last_name}`
const response = await $fetch<{ user: User }>( const response = await $fetch<AuthResponse>(`${apiBase}/auth/register`, {
'https://easysite102.ru/api/auth/register',
{
method: 'POST', method: 'POST',
body: { body: registerData
...registerData, })
full_name
} user.value = response.user
} localStorage.setItem('auth_token', response.token)
)
return response return response
// eslint-disable-next-line no-useless-catch
} catch (error) { } catch (error) {
throw error throw error
} finally { } finally {
@@ -57,9 +49,11 @@ export const useAuth = () => {
} }
const logout = async () => { const logout = async () => {
const token = localStorage.getItem('auth_token')
try { try {
await $fetch('https://easysite102.ru/api/auth/logout', { await $fetch(`${apiBase}/auth/logout`, {
method: 'POST' method: 'POST',
headers: { Authorization: `Bearer ${token}` }
}) })
} catch (error) { } catch (error) {
console.error('Logout error:', error) console.error('Logout error:', error)
@@ -75,14 +69,9 @@ export const useAuth = () => {
if (!token) return if (!token) return
try { try {
const response = await $fetch<{ user: User }>( const response = await $fetch<{ user: UserInfo }>(`${apiBase}/auth/me`, {
'https://easysite102.ru/api/auth/me', headers: { Authorization: `Bearer ${token}` }
{ })
headers: {
Authorization: `Bearer ${token}`
}
}
)
user.value = response.user user.value = response.user
} catch (error) { } catch (error) {
console.error('Auth check failed:', error) console.error('Auth check failed:', error)
@@ -0,0 +1,87 @@
import type {
ObjectShortResponse,
ObjectResponse,
ObjectListResponse,
CreateObjectRequest,
UpdateObjectRequest
} from '~/types/objects'
export const useObjects = () => {
const config = useRuntimeConfig()
const apiBase = config.public.apiBase
const getAuthHeaders = () => {
const token = localStorage.getItem('auth_token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
const getList = async (params?: {
page?: number
page_size?: number
type?: string
q?: string
status?: string
is_active?: boolean
}) => {
return $fetch<ObjectListResponse>(`${apiBase}/objects`, {
params
})
}
const getById = async (id: number) => {
return $fetch<ObjectResponse>(`${apiBase}/objects/${id}`)
}
const getMy = async (params?: { page?: number; page_size?: number; status?: string }) => {
return $fetch<ObjectListResponse>(`${apiBase}/objects/my`, {
headers: getAuthHeaders(),
params
})
}
const getByOwner = async (ownerId: number, params?: { page?: number; page_size?: number }) => {
return $fetch<ObjectListResponse>(`${apiBase}/objects/owner/${ownerId}`, {
params
})
}
const search = async (q: string, params?: { page?: number; page_size?: number }) => {
return $fetch<ObjectListResponse>(`${apiBase}/objects/search`, {
params: { q, ...params }
})
}
const create = async (data: CreateObjectRequest) => {
return $fetch<ObjectResponse>(`${apiBase}/objects`, {
method: 'POST',
headers: getAuthHeaders(),
body: data
})
}
const update = async (id: number, data: UpdateObjectRequest) => {
return $fetch<ObjectResponse>(`${apiBase}/objects/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: data
})
}
const remove = async (id: number) => {
return $fetch<void>(`${apiBase}/objects/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
}
return {
getList,
getById,
getMy,
getByOwner,
search,
create,
update,
remove
}
}
@@ -3,7 +3,6 @@
<main class="edit-object-page"> <main class="edit-object-page">
<div class="container max-w-4xl"> <div class="container max-w-4xl">
<!-- Заголовок -->
<div class="page-header"> <div class="page-header">
<div class="header-content"> <div class="header-content">
<div class="header-main"> <div class="header-main">
@@ -16,13 +15,11 @@
</div> </div>
</div> </div>
<!-- Загрузка -->
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<div class="loading-spinner"/> <div class="loading-spinner"/>
<p class="loading-text">Загрузка данных объекта...</p> <p class="loading-text">Загрузка данных объекта...</p>
</div> </div>
<!-- Форма -->
<ObjectForm <ObjectForm
v-else-if="object" v-else-if="object"
:object="object" :object="object"
@@ -30,7 +27,6 @@
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" /> @cancel="handleCancel" />
<!-- Объект не найден -->
<div v-else class="error-state"> <div v-else class="error-state">
<div class="error-icon"></div> <div class="error-icon"></div>
<h3 class="error-title">Объект не найден</h3> <h3 class="error-title">Объект не найден</h3>
@@ -51,70 +47,46 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface ObjectData {
id: number
title: string
type: string
description: string
city: string
address: string
price: number
images: string[]
amenities: string[]
contact: {
phone: string
email: string
}
}
const route = useRoute() const route = useRoute()
const object = ref<ObjectData | null>(null) const { getById, update } = useObjects()
const object = ref<Record<string, unknown> | null>(null)
const loading = ref(true) const loading = ref(true)
const updating = ref(false) const updating = ref(false)
// Мок-данные объекта
const mockObject: ObjectData = {
id: 1,
title: 'Гостевой дом "У озера"',
type: 'guest_house',
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты.',
city: 'Карелия',
address: 'ул. Озерная, 15',
price: 2800,
images: [
'/images/objects/lake-house-1.jpg',
'/images/objects/lake-house-2.jpg'
],
amenities: ['Wi-Fi', 'Парковка', 'Завтрак', 'Баня'],
contact: {
phone: '+7 (911) 123-45-67',
email: 'lakehouse@example.com'
}
}
onMounted(async () => { onMounted(async () => {
// Имитация загрузки данных loading.value = true
await new Promise(resolve => setTimeout(resolve, 800))
object.value = mockObject
loading.value = false
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSubmit = async (formData: any) => {
updating.value = true
try { try {
// Имитация обновления const id = parseInt(route.params.id as string)
await new Promise(resolve => setTimeout(resolve, 1000)) const data = await getById(id)
object.value = {
console.log('Обновление объекта:', { short_name: data.short_name,
id: parseInt(route.params.id as string), long_name: data.long_name,
...formData type: data.type,
description: data.description,
short_description: data.short_description,
address: data.address,
price: data.price,
price_period: data.price_period,
phone: data.phone,
email: data.email,
site: data.site,
status: data.status
}
} catch (error) {
console.error('Error loading object:', error)
object.value = null
} finally {
loading.value = false
}
}) })
alert('Объект успешно обновлен!') const handleSubmit = async (formData: Record<string, unknown>) => {
await navigateTo(`/objects/${route.params.id}`) updating.value = true
try {
const id = parseInt(route.params.id as string)
await update(id, formData as Parameters<typeof update>[1])
await navigateTo(`/objects/${id}`)
} catch (error) { } catch (error) {
console.error('Error updating object:', error) console.error('Error updating object:', error)
alert('Ошибка при обновлении объекта') alert('Ошибка при обновлении объекта')
@@ -124,8 +96,7 @@ const handleSubmit = async (formData: any) => {
} }
const handleCancel = () => { const handleCancel = () => {
const objectId = route.params.id navigateTo(`/objects/${route.params.id}`)
navigateTo(`/objects/${objectId}`)
} }
</script> </script>
@@ -232,16 +203,10 @@ const handleCancel = () => {
} }
@keyframes spin { @keyframes spin {
0% { 0% { transform: rotate(0deg); }
transform: rotate(0deg); 100% { transform: rotate(360deg); }
} }
100% {
transform: rotate(360deg);
}
}
/* Адаптивность */
@media (max-width: 768px) { @media (max-width: 768px) {
.header-main { .header-main {
flex-direction: column; flex-direction: column;
@@ -257,19 +222,5 @@ const handleCancel = () => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.error-actions .btn {
min-width: 200px;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.error-state {
padding: var(--space-xl);
}
} }
</style> </style>
@@ -3,68 +3,75 @@
<main class="object-page"> <main class="object-page">
<div class="container max-w-6xl"> <div class="container max-w-6xl">
<!-- Хлебные крошки -->
<nav class="breadcrumbs"> <nav class="breadcrumbs">
<NuxtLink to="/objects" class="breadcrumb-link">Все объекты</NuxtLink> <NuxtLink to="/objects" class="breadcrumb-link">Все объекты</NuxtLink>
<span class="breadcrumb-separator">/</span> <span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{ object?.title }}</span> <span class="breadcrumb-current">{{ object?.title || object?.short_name }}</span>
</nav> </nav>
<!-- Заголовок и действия --> <div v-if="loading" class="loading-state">
<div class="loading-spinner"/>
<p class="loading-text">Загрузка объекта...</p>
</div>
<div v-else-if="!object" class="empty-state">
<div class="empty-icon">🏢</div>
<h3 class="empty-title">Объект не найден</h3>
<p class="empty-description">Возможно, объект был удалён или у вас нет к нему доступа</p>
<NuxtLink to="/objects" class="btn btn-primary">Вернуться к списку</NuxtLink>
</div>
<template v-else>
<div class="page-header"> <div class="page-header">
<div class="header-content"> <div class="header-content">
<div class="header-text"> <div class="header-text">
<div class="object-meta"> <div class="object-meta">
<span class="object-type">{{ getTypeLabel(object?.type) }}</span> <span class="object-type">{{ getTypeLabel(object.type) }}</span>
<div class="rating"> <div class="rating">
<div class="rating-stars"> <div class="rating-stars">
<span <span
v-for="star in 5" v-for="star in 5"
:key="star" :key="star"
class="rating-star" class="rating-star"
:class="{ empty: star > Math.round(object?.rating || 0) }"> :class="{ empty: star > Math.round(touristScore) }">
</span> </span>
</div> </div>
<span class="rating-value">{{ object?.rating }}</span> <span class="rating-value">{{ touristScore }}</span>
<span class="reviews-count">({{ object?.reviewsCount }} отзывов)</span> <span class="reviews-count">({{ object.feedback_count }} отзывов)</span>
</div> </div>
</div> </div>
<h1 class="page-title">{{ object?.title }}</h1> <h1 class="page-title">{{ object.title || object.short_name }}</h1>
<div class="object-location"> <div class="object-location">
<span class="location-icon">📍</span> <span class="location-icon">📍</span>
{{ object?.city }}, {{ object?.address }} {{ object.address }}
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="price-section"> <div class="price-section">
<div class="price">{{ formatPrice(object?.price) }}</div> <div class="price">{{ formatPrice(object.price) }}</div>
<div class="price-period">за ночь</div> <div class="price-period">{{ pricePeriodLabel }}</div>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
<button class="btn btn-primary btn-large" @click="showBookingModal = true"> <button class="btn btn-primary btn-large" @click="showBookingModal = true">
Забронировать Забронировать
</button> </button>
<button class="btn btn-outline btn-with-icon">
<span></span>
В избранное
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Галерея изображений -->
<div class="gallery-section"> <div class="gallery-section">
<div class="main-image"> <div class="main-image">
<img :src="object?.images[0]" :alt="object?.title" class="gallery-image" @click="openGallery(0)" > <img :src="mainImage" :alt="object.title || object.short_name" class="gallery-image" @click="openGallery(0)">
</div> </div>
<div v-if="object?.images && object.images.length > 1" class="thumbnails"> <div v-if="object.images && object.images.length > 1" class="thumbnails">
<div <div
v-for="(image, index) in object.images.slice(1, 5)" v-for="(image, index) in object.images.slice(1, 5)"
:key="index" class="thumbnail" :key="image.id"
class="thumbnail"
@click="openGallery(index + 1)"> @click="openGallery(index + 1)">
<img :src="image" :alt="`${object.title} - фото ${index + 2}`"> <img :src="image.url" :alt="`${object.title} - фото ${index + 2}`">
<div v-if="index === 3 && object.images.length > 5" class="more-images"> <div v-if="index === 3 && object.images.length > 5" class="more-images">
+{{ object.images.length - 5 }} +{{ object.images.length - 5 }}
</div> </div>
@@ -72,110 +79,53 @@
</div> </div>
</div> </div>
<!-- Основной контент -->
<div class="content-grid"> <div class="content-grid">
<!-- Информация об объекте -->
<div class="content-main"> <div class="content-main">
<!-- Описание -->
<section class="content-section"> <section class="content-section">
<h2 class="section-title">Описание</h2> <h2 class="section-title">Описание</h2>
<p class="object-description">{{ object?.description }}</p> <p class="object-description">{{ object.description || object.short_description }}</p>
</section> </section>
<!-- Удобства --> <section v-if="object.amenities && object.amenities.length" class="content-section">
<section class="content-section">
<h2 class="section-title">Удобства</h2> <h2 class="section-title">Удобства</h2>
<div class="amenities-grid"> <div class="amenities-grid">
<div v-for="amenity in object?.amenities" :key="amenity" class="amenity-item"> <div v-for="amenity in object.amenities" :key="amenity.id" class="amenity-item">
<span class="amenity-icon"></span> <span class="amenity-icon">{{ amenity.icon || '✅' }}</span>
<span class="amenity-text">{{ amenity }}</span> <span class="amenity-text">{{ amenity.name }}</span>
</div> </div>
</div> </div>
</section> </section>
<!-- Контакты -->
<section class="content-section"> <section class="content-section">
<h2 class="section-title">Контакты</h2> <h2 class="section-title">Контакты</h2>
<div class="contact-info"> <div class="contact-info">
<div class="contact-item"> <div v-if="object.phone" class="contact-item">
<span class="contact-icon">📞</span> <span class="contact-icon">📞</span>
<a :href="`tel:${object?.contact.phone}`" class="contact-link"> <a :href="`tel:${object.phone}`" class="contact-link">{{ object.phone }}</a>
{{ object?.contact.phone }}
</a>
</div> </div>
<div class="contact-item"> <div v-if="object.email" class="contact-item">
<span class="contact-icon"></span> <span class="contact-icon"></span>
<a :href="`mailto:${object?.contact.email}`" class="contact-link"> <a :href="`mailto:${object.email}`" class="contact-link">{{ object.email }}</a>
{{ object?.contact.email }}
</a>
</div> </div>
<div class="contact-item"> <div v-if="object.site" class="contact-item">
<span class="contact-icon">🌐</span>
<a :href="object.site" target="_blank" class="contact-link">{{ object.site }}</a>
</div>
<div v-if="object.address" class="contact-item">
<span class="contact-icon">📍</span> <span class="contact-icon">📍</span>
<span class="contact-text">{{ object?.address }}, {{ object?.city }}</span> <span class="contact-text">{{ object.address }}</span>
</div>
</div>
</section>
<!-- Отзывы -->
<section v-if="reviews.length > 0" class="content-section">
<div class="section-header">
<h2 class="section-title">Отзывы</h2>
<div class="reviews-summary">
<div class="average-rating">{{ object?.rating }}</div>
<div class="reviews-stats">
<div class="rating-stars small">
<span
v-for="star in 5"
:key="star"
class="rating-star"
:class="{ empty: star > Math.round(object?.rating || 0) }">
</span>
</div>
<div class="reviews-count">{{ object?.reviewsCount }} отзывов</div>
</div>
</div>
</div>
<div class="reviews-list">
<div v-for="review in reviews" :key="review.id" class="review-card">
<div class="review-header">
<div class="reviewer-info">
<div class="reviewer-avatar">
{{ review.author.name.charAt(0) }}
</div>
<div class="reviewer-details">
<div class="reviewer-name">{{ review.author.name }}</div>
<div class="review-date">{{ formatDate(review.date) }}</div>
</div>
</div>
<div class="review-rating">
<div class="rating-stars small">
<span
v-for="star in 5"
:key="star"
class="rating-star"
:class="{ empty: star > review.rating }">
</span>
</div>
</div>
</div>
<p class="review-text">{{ review.text }}</p>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<!-- Боковая панель -->
<div class="content-sidebar"> <div class="content-sidebar">
<!-- Блок бронирования -->
<div class="booking-card card"> <div class="booking-card card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Бронирование</h3> <h3 class="card-title">Бронирование</h3>
<div class="price-info"> <div class="price-info">
<span class="price-large">{{ formatPrice(object?.price) }}</span> <span class="price-large">{{ formatPrice(object.price) }}</span>
<span class="price-period">за ночь</span> <span class="price-period">{{ pricePeriodLabel }}</span>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -206,18 +156,17 @@
</div> </div>
</div> </div>
<!-- Контактная информация -->
<div class="contact-card card"> <div class="contact-card card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Контактная информация</h3> <h3 class="card-title">Контактная информация</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="contact-actions"> <div class="contact-actions">
<a :href="`tel:${object?.contact.phone}`" class="btn btn-outline btn-with-icon"> <a v-if="object.phone" :href="`tel:${object.phone}`" class="btn btn-outline btn-with-icon">
<span>📞</span> <span>📞</span>
Позвонить Позвонить
</a> </a>
<a :href="`mailto:${object?.contact.email}`" class="btn btn-outline btn-with-icon"> <a v-if="object.email" :href="`mailto:${object.email}`" class="btn btn-outline btn-with-icon">
<span></span> <span></span>
Написать Написать
</a> </a>
@@ -225,175 +174,105 @@
</div> </div>
</div> </div>
<!-- Действия владельца -->
<div v-if="isOwner" class="owner-actions card"> <div v-if="isOwner" class="owner-actions card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Управление объектом</h3> <h3 class="card-title">Управление объектом</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="action-buttons"> <div class="action-buttons">
<NuxtLink :to="`/objects/${object?.id}/edit`" class="btn btn-outline btn-with-icon"> <NuxtLink :to="`/objects/${object.id}/edit`" class="btn btn-outline btn-with-icon">
<span></span> <span></span>
Редактировать Редактировать
</NuxtLink> </NuxtLink>
<button <button
class="btn btn-outline btn-with-icon" class="btn btn-outline btn-with-icon"
:class="{ 'btn-primary': !object?.isActive }" :class="{ 'btn-primary': !object.is_active }"
@click="toggleObjectStatus"> @click="toggleObjectStatus">
<span>{{ object?.isActive ? '⏸️' : '▶️' }}</span> <span>{{ object.is_active ? '⏸️' : '▶️' }}</span>
{{ object?.isActive ? 'Деактивировать' : 'Активировать' }} {{ object.is_active ? 'Деактивировать' : 'Активировать' }}
</button>
<button
class="btn btn-outline btn-with-icon delete-btn"
@click="deleteObject">
<span>🗑</span>
Удалить
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</main> </main>
<!-- Модальное окно бронирования -->
<BookingModal
v-if="showBookingModal"
:object="object"
:dates="bookingDates"
:guests="bookingGuests"
@close="showBookingModal = false"
@confirm="handleBooking" />
<!-- Галерея изображений -->
<ImageGallery
v-if="showGallery"
:images="object?.images || []"
:initial-index="galleryIndex"
@close="showGallery = false" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ObjectResponse } from '~/types/objects'
interface ObjectData {
id: number
title: string
type: string
description: string
city: string
address: string
price: number
rating: number
reviewsCount: number
images: string[]
amenities: string[]
contact: {
phone: string
email: string
}
isActive: boolean
ownerId: number
}
interface Review {
id: number
author: {
name: string
avatar?: string
}
rating: number
text: string
date: string
}
// Route и состояние
const route = useRoute() const route = useRoute()
const object = ref<ObjectData | null>(null) const { getById, remove } = useObjects()
const reviews = ref<Review[]>([]) const { user } = useAuth()
const object = ref<ObjectResponse | null>(null)
const loading = ref(true) const loading = ref(true)
const showBookingModal = ref(false) const showBookingModal = ref(false)
const showGallery = ref(false) const showGallery = ref(false)
const galleryIndex = ref(0) const galleryIndex = ref(0)
const isOwner = ref(false)
// Данные бронирования const bookingDates = ref({ checkIn: '', checkOut: '' })
const bookingDates = ref({
checkIn: '',
checkOut: ''
})
const bookingGuests = ref('2') const bookingGuests = ref('2')
// Мок-данные объекта const touristScore = computed(() => {
const mockObject: ObjectData = { return object.value?.feedback_count || 0
id: parseInt(route.params.id as string), })
title: 'Гостевой дом "У озера"',
type: 'guest_house',
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты. Предлагаем комфортабельные номера с видом на озеро, домашнюю кухню и множество развлечений на природе.',
city: 'Карелия',
address: 'ул. Озерная, 15',
price: 2800,
rating: 4.8,
reviewsCount: 24,
images: [
'/images/objects/lake-house-1.jpg',
'/images/objects/lake-house-2.jpg',
'/images/objects/lake-house-3.jpg',
'/images/objects/lake-house-4.jpg',
'/images/objects/lake-house-5.jpg'
],
amenities: ['Wi-Fi', 'Парковка', 'Завтрак', 'Баня', 'Прокат велосипедов', 'Рыбалка', 'Камин'],
contact: {
phone: '+7 (911) 123-45-67',
email: 'lakehouse@example.com'
},
isActive: true,
ownerId: 1
}
// Мок-отзывы const mainImage = computed(() => {
const mockReviews: Review[] = [ if (object.value?.images?.length) {
{ return object.value.images[0].url
id: 1,
author: { name: 'Анна Петрова' },
rating: 5,
text: 'Прекрасное место для отдыха! Очень уютные номера, вкусные завтраки и великолепный вид на озеро. Обязательно вернемся снова.',
date: '2024-01-15'
},
{
id: 2,
author: { name: 'Иван Сидоров' },
rating: 4,
text: 'Отличный гостевой дом, все понравилось. Особенно порадовала баня и рыбалка. Персонал очень внимательный и доброжелательный.',
date: '2024-01-10'
} }
] return '/images/placeholder.jpg'
})
const pricePeriodLabel = computed(() => {
const labels: Record<string, string> = {
per_night: 'за ночь',
per_person: 'за человека',
per_tour: 'за тур',
per_hour: 'за час'
}
const p = object.value?.price_period
return p ? labels[p] || p : ''
})
const isOwner = computed(() => {
if (!user.value || !object.value) return false
return user.value.id === object.value.owner_id
})
// Загрузка данных
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = true
try { try {
// Имитация загрузки данных const id = parseInt(route.params.id as string)
await new Promise(resolve => setTimeout(resolve, 800)) object.value = await getById(id)
object.value = mockObject
reviews.value = mockReviews
// Проверка владельца (в реальном приложении - по ID пользователя)
isOwner.value = object.value.ownerId === 1
// SEO
useSeoMeta({ useSeoMeta({
title: `${object.value.title} - EasySite`, title: `${object.value.title || object.value.short_name} - EasySite`,
description: object.value.description, description: object.value.description || object.value.short_description,
ogTitle: object.value.title, ogTitle: object.value.title || object.value.short_name,
ogDescription: object.value.description, ogDescription: object.value.description || object.value.short_description,
ogImage: object.value.images[0] ogImage: mainImage.value
}) })
} catch (error) { } catch (error) {
console.error('Error loading object:', error) console.error('Error loading object:', error)
showError({ statusCode: 404, statusMessage: 'Объект не найден' }) object.value = null
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
// Методы
const getTypeLabel = (type: string | undefined) => { const getTypeLabel = (type: string | undefined) => {
const types: Record<string, string> = { const types: Record<string, string> = {
hotel: '🏨 Гостиница', hotel: '🏨 Гостиница',
@@ -402,11 +281,11 @@ const getTypeLabel = (type: string | undefined) => {
tour: '🧳 Тур', tour: '🧳 Тур',
restaurant: '🍴 Ресторан' restaurant: '🍴 Ресторан'
} }
return types[type || ''] || type return types[type || ''] || type || ''
} }
const formatPrice = (price: number | undefined) => { const formatPrice = (price: number | undefined) => {
if (!price) return '0 ₽' if (!price && price !== 0) return ''
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', style: 'currency',
currency: 'RUB', currency: 'RUB',
@@ -414,14 +293,6 @@ const formatPrice = (price: number | undefined) => {
}).format(price) }).format(price)
} }
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
const openGallery = (index: number) => { const openGallery = (index: number) => {
galleryIndex.value = index galleryIndex.value = index
showGallery.value = true showGallery.value = true
@@ -429,15 +300,24 @@ const openGallery = (index: number) => {
const toggleObjectStatus = async () => { const toggleObjectStatus = async () => {
if (!object.value) return if (!object.value) return
// TODO: Implement status toggle via update API
object.value.isActive = !object.value.isActive object.value.is_active = !object.value.is_active
// В реальном приложении здесь был бы API-запрос
} }
const handleBooking = (bookingData: unknown) => { const deleteObject = async () => {
console.log('Booking confirmed:', bookingData) if (!object.value) return
if (!confirm('Вы уверены, что хотите удалить этот объект?')) return
try {
await remove(object.value.id)
await navigateTo('/objects/my-objects')
} catch (error) {
console.error('Error deleting object:', error)
alert('Ошибка при удалении объекта')
}
}
const handleBooking = () => {
showBookingModal.value = false showBookingModal.value = false
// Здесь обработка бронирования
} }
</script> </script>
@@ -535,10 +415,6 @@ const handleBooking = (bookingData: unknown) => {
color: var(--gray-300); color: var(--gray-300);
} }
.rating-stars.small .rating-star {
font-size: var(--text-sm);
}
.rating-value { .rating-value {
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
color: var(--text-primary); color: var(--text-primary);
@@ -612,7 +488,6 @@ const handleBooking = (bookingData: unknown) => {
gap: var(--space-sm); gap: var(--space-sm);
} }
/* Галерея */
.gallery-section { .gallery-section {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
@@ -680,7 +555,6 @@ const handleBooking = (bookingData: unknown) => {
font-size: var(--text-lg); font-size: var(--text-lg);
} }
/* Основной контент */
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
@@ -710,32 +584,6 @@ const handleBooking = (bookingData: unknown) => {
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
}
.reviews-summary {
display: flex;
align-items: center;
gap: var(--space-md);
}
.average-rating {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
}
.reviews-stats {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.object-description { .object-description {
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
@@ -799,74 +647,6 @@ const handleBooking = (bookingData: unknown) => {
color: var(--text-primary); color: var(--text-primary);
} }
.reviews-list {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.review-card {
padding: var(--space-lg);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.review-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-md);
}
.reviewer-info {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.reviewer-avatar {
width: 3rem;
height: 3rem;
background: var(--primary-500);
color: var(--text-inverse);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-bold);
font-size: var(--text-lg);
}
.reviewer-details {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.reviewer-name {
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.review-date {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.review-rating {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.review-text {
line-height: var(--leading-relaxed);
color: var(--text-secondary);
margin: 0;
}
/* Боковая панель */
.content-sidebar { .content-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -908,13 +688,46 @@ const handleBooking = (bookingData: unknown) => {
gap: var(--space-sm); gap: var(--space-sm);
} }
.owner-actions .action-buttons { .delete-btn {
display: flex; color: var(--danger-500);
flex-direction: column; border-color: var(--danger-200);
gap: var(--space-sm); }
.delete-btn:hover {
background: var(--danger-50);
border-color: var(--danger-500);
}
.loading-state,
.empty-state {
text-align: center;
padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.loading-spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-light);
border-top: 3px solid var(--primary-500);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
}
.empty-icon {
font-size: 4rem;
margin-bottom: var(--space-lg);
opacity: 0.7;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
} }
/* Адаптивность */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.content-grid { .content-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -958,16 +771,6 @@ const handleBooking = (bookingData: unknown) => {
font-size: var(--text-2xl); font-size: var(--text-2xl);
} }
.content-section {
padding: var(--space-lg);
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-md);
}
.amenities-grid { .amenities-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -984,23 +787,14 @@ const handleBooking = (bookingData: unknown) => {
gap: var(--space-sm); gap: var(--space-sm);
} }
.gallery-section {
height: auto;
}
.thumbnails { .thumbnails {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
height: auto;
} }
.thumbnail { .thumbnail {
height: 80px; height: 80px;
} }
.action-buttons .btn {
width: 100%;
}
.date-inputs { .date-inputs {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -2,7 +2,6 @@
<div class="page-wrapper"> <div class="page-wrapper">
<main class="create-object-page"> <main class="create-object-page">
<div class="container max-w-4xl"> <div class="container max-w-4xl">
<!-- Заголовок -->
<div class="page-header"> <div class="page-header">
<div class="header-content"> <div class="header-content">
<div class="header-text"> <div class="header-text">
@@ -15,7 +14,6 @@
</div> </div>
</div> </div>
<!-- Форма -->
<ObjectForm :loading="loading" @submit="handleSubmit" @cancel="handleCancel" /> <ObjectForm :loading="loading" @submit="handleSubmit" @cancel="handleCancel" />
</div> </div>
</main> </main>
@@ -24,28 +22,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { create } = useObjects()
const loading = ref(false) const loading = ref(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const handleSubmit = async (formData: Record<string, unknown>) => {
const handleSubmit = async (formData: any) => {
loading.value = true loading.value = true
try { try {
// Имитация создания объекта await create(formData as Parameters<typeof create>[0])
await new Promise(resolve => setTimeout(resolve, 1000)) navigateTo('/objects/my-objects')
console.log('Создание объекта:', {
...formData,
userId: 1,
isActive: true,
images: formData.images || ['/images/placeholder.jpg'],
amenities: formData.amenities || []
})
// Показываем уведомление об успехе
alert('Объект успешно создан!')
await navigateTo('/objects/my-objects')
} catch (error) { } catch (error) {
console.error('Error creating object:', error) console.error('Error creating object:', error)
alert('Ошибка при создании объекта') alert('Ошибка при создании объекта')
@@ -79,11 +63,13 @@ const handleCancel = () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-lg); gap: var(--space-xl);
} }
.header-text { .header-text {
flex: 1; display: flex;
flex-direction: column;
gap: var(--space-xs);
} }
.page-title { .page-title {
@@ -91,34 +77,18 @@ const handleCancel = () => {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: var(--font-bold); font-weight: var(--font-bold);
color: var(--text-primary); color: var(--text-primary);
margin-bottom: var(--space-xs); margin: 0;
} }
.page-subtitle { .page-subtitle {
font-size: var(--text-lg); font-size: var(--text-lg);
color: var(--text-secondary); color: var(--text-secondary);
margin: 0;
} }
/* Адаптивность */
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { .header-content {
flex-direction: column; flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.header-content .btn {
align-self: flex-start;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.page-title {
font-size: var(--text-2xl);
} }
} }
</style> </style>
@@ -3,12 +3,11 @@
<main class="objects-page"> <main class="objects-page">
<div class="container"> <div class="container">
<!-- Заголовок и действия -->
<div class="page-header"> <div class="page-header">
<div class="header-content"> <div class="header-content">
<div class="header-text"> <div class="header-text">
<h1 class="page-title">Все объекты</h1> <h1 class="page-title">Все объекты</h1>
<p class="page-subtitle">Найдено {{ filteredObjects.length }} объектов</p> <p class="page-subtitle">Найдено {{ totalObjects }} объектов</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-outline btn-with-icon" @click="showFilters = !showFilters"> <button class="btn btn-outline btn-with-icon" @click="showFilters = !showFilters">
@@ -25,7 +24,6 @@
</div> </div>
</div> </div>
<!-- Быстрые фильтры -->
<div class="quick-filters"> <div class="quick-filters">
<button <button
v-for="type in quickTypes" v-for="type in quickTypes"
@@ -38,7 +36,6 @@
</div> </div>
</div> </div>
<!-- Расширенные фильтры -->
<div v-if="showFilters" class="search-filters card"> <div v-if="showFilters" class="search-filters card">
<div class="filter-grid"> <div class="filter-grid">
<div class="form-group"> <div class="form-group">
@@ -52,46 +49,27 @@
<option value="guest_house">🏡 Гостевой дом</option> <option value="guest_house">🏡 Гостевой дом</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Город</label> <label class="form-label">Поиск</label>
<input v-model="filters.city" type="text" class="form-input" placeholder="Введите город"> <input v-model="filters.q" type="text" class="form-input" placeholder="Название или адрес">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Цена до</label> <label class="form-label">Цена до</label>
<input v-model="filters.maxPrice" type="number" class="form-input" placeholder="Макс. цена"> <input v-model="filters.maxPrice" type="number" class="form-input" placeholder="Макс. цена">
</div> </div>
<div class="form-group">
<label class="form-label">Рейтинг от</label>
<select v-model="filters.minRating" class="form-select">
<option value="0">Любой рейтинг</option>
<option value="4"> 4.0+</option>
<option value="4.5"> 4.5+</option>
<option value="4.8"> 4.8+</option>
</select>
</div> </div>
</div>
<div class="filter-actions"> <div class="filter-actions">
<button class="btn btn-primary" @click="applyFilters"> <button class="btn btn-primary" @click="applyFilters">Применить фильтры</button>
Применить фильтры <button class="btn btn-outline" @click="resetFilters">Сбросить</button>
</button>
<button class="btn btn-outline" @click="resetFilters">
Сбросить
</button>
</div> </div>
</div> </div>
<!-- Управление видом -->
<div class="view-controls"> <div class="view-controls">
<div class="sort-controls"> <div class="sort-controls">
<select v-model="sortBy" class="form-select"> <select v-model="sortBy" class="form-select">
<option value="title">По названию</option> <option value="title">По названию</option>
<option value="price">По цене</option> <option value="price">По цене</option>
<option value="rating">По рейтингу</option> <option value="rating">По рейтингу</option>
<option value="city">По городу</option>
</select> </select>
<button class="btn btn-outline btn-sm" @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'"> <button class="btn btn-outline btn-sm" @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
{{ sortOrder === 'asc' ? '↑' : '↓' }} {{ sortOrder === 'asc' ? '↑' : '↓' }}
@@ -112,22 +90,18 @@
</div> </div>
</div> </div>
<!-- Результаты -->
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<div class="loading-spinner"/> <div class="loading-spinner"/>
<p class="loading-text">Загрузка объектов...</p> <p class="loading-text">Загрузка объектов...</p>
</div> </div>
<div v-else-if="filteredObjects.length === 0" class="empty-state"> <div v-else-if="objects.length === 0" class="empty-state">
<div class="empty-icon">🏢</div> <div class="empty-icon">🏢</div>
<h3 class="empty-title">Объекты не найдены</h3> <h3 class="empty-title">Объекты не найдены</h3>
<p class="empty-description">Попробуйте изменить параметры поиска</p> <p class="empty-description">Попробуйте изменить параметры поиска</p>
<button class="btn btn-primary" @click="resetFilters"> <button class="btn btn-primary" @click="resetFilters">Сбросить фильтры</button>
Сбросить фильтры
</button>
</div> </div>
<!-- Сетка объектов -->
<div v-else class="objects-grid" :class="viewMode === 'grid' ? 'grid-view' : 'list-view'"> <div v-else class="objects-grid" :class="viewMode === 'grid' ? 'grid-view' : 'list-view'">
<ObjectCard <ObjectCard
v-for="object in paginatedObjects" v-for="object in paginatedObjects"
@@ -137,8 +111,7 @@
@click="navigateToObject(object.id)" /> @click="navigateToObject(object.id)" />
</div> </div>
<!-- Пагинация --> <div v-if="!loading && objects.length > 0" class="pagination">
<div v-if="!loading && filteredObjects.length > 0" class="pagination">
<button <button
v-for="page in totalPages" v-for="page in totalPages"
:key="page" :key="page"
@@ -155,44 +128,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ObjectShortResponse } from '~/types/objects'
interface ObjectItem { useHead({ title: 'Все объекты - EasySite' })
id: number
title: string
type: string
city: string
price: number
rating: number
image: string
description: string
isActive: boolean
createdAt: string
}
// Навигация const { getList } = useObjects()
useHead({
title: 'Все объекты - EasySite'
})
// Состояние const objects = ref<ObjectShortResponse[]>([])
const objects = ref<ObjectItem[]>([]) const totalObjects = ref(0)
const loading = ref(true) const loading = ref(true)
const showFilters = ref(false) const showFilters = ref(false)
const viewMode = ref<'grid' | 'list'>('grid') const viewMode = ref<'grid' | 'list'>('grid')
const sortBy = ref<'title' | 'price' | 'rating' | 'city'>('title') const sortBy = ref<'title' | 'price' | 'rating'>('title')
const sortOrder = ref<'asc' | 'desc'>('asc') const sortOrder = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1) const currentPage = ref(1)
const itemsPerPage = 9 const itemsPerPage = 9
const filters = ref({ const filters = ref({
search: '', q: '',
type: '', type: '',
city: '', maxPrice: null as number | null
maxPrice: null as number | null,
minRating: 0
}) })
// Быстрые фильтры
const quickTypes = [ const quickTypes = [
{ value: 'hotel', label: 'Гостиницы', icon: '🏨' }, { value: 'hotel', label: 'Гостиницы', icon: '🏨' },
{ value: 'sanatorium', label: 'Санатории', icon: '🏥' }, { value: 'sanatorium', label: 'Санатории', icon: '🏥' },
@@ -200,107 +157,28 @@ const quickTypes = [
{ value: 'restaurant', label: 'Рестораны', icon: '🍴' } { value: 'restaurant', label: 'Рестораны', icon: '🍴' }
] ]
// Мок-данные
const mockObjects: ObjectItem[] = [
{
id: 1,
title: 'Горный отель "Эдельвейс"',
type: 'hotel',
city: 'Сочи',
price: 4500,
rating: 4.8,
image: '/images/hotels/edelweiss.jpg',
description: 'Комфортабельный отель в горах с видом на море. Идеальное место для отдыха от городской суеты.',
isActive: true,
createdAt: '2024-01-15'
},
{
id: 2,
title: 'Санаторий "Здоровье"',
type: 'sanatorium',
city: 'Кисловодск',
price: 3200,
rating: 4.6,
image: '/images/sanatoriums/health.jpg',
description: 'Лечебно-оздоровительный комплекс с минеральными водами и современным оборудованием.',
isActive: true,
createdAt: '2024-01-10'
},
{
id: 3,
title: 'Тур по Золотому кольцу',
type: 'tour',
city: 'Москва',
price: 12500,
rating: 4.9,
image: '/images/tours/golden-ring.jpg',
description: '7-дневный тур по древним городам России с опытным гидом и комфортабельным транспортом.',
isActive: true,
createdAt: '2024-01-08'
}
]
// Вычисляемые свойства
const activeFiltersCount = computed(() => { const activeFiltersCount = computed(() => {
return Object.values(filters.value).filter(val => return Object.values(filters.value).filter(val =>
val !== '' && val !== null && val !== 0 val !== '' && val !== null
).length ).length
}) })
const filteredObjects = computed(() => {
let filtered = [...objects.value]
if (filters.value.search) {
const search = filters.value.search.toLowerCase()
filtered = filtered.filter(obj =>
obj.title.toLowerCase().includes(search) ||
obj.city.toLowerCase().includes(search) ||
obj.description.toLowerCase().includes(search)
)
}
if (filters.value.type) {
filtered = filtered.filter(obj => obj.type === filters.value.type)
}
if (filters.value.city) {
filtered = filtered.filter(obj =>
obj.city.toLowerCase().includes(filters.value.city.toLowerCase())
)
}
if (filters.value.maxPrice) {
filtered = filtered.filter(obj => obj.price <= filters.value.maxPrice!)
}
if (filters.value.minRating) {
filtered = filtered.filter(obj => obj.rating >= filters.value.minRating!)
}
return filtered
})
const sortedObjects = computed(() => { const sortedObjects = computed(() => {
const sorted = [...filteredObjects.value].sort((a, b) => { const sorted = [...objects.value].sort((a, b) => {
let aVal = a[sortBy.value] if (sortBy.value === 'price') {
let bVal = b[sortBy.value] const aVal = a.price || 0
const bVal = b.price || 0
if (sortBy.value === 'price' || sortBy.value === 'rating') { return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
const aNum = Number(aVal)
const bNum = Number(bVal)
return sortOrder.value === 'asc' ? aNum - bNum : bNum - aNum
} }
if (sortBy.value === 'rating') {
aVal = String(aVal).toLowerCase() const aVal = a.tourist_average_score || a.entrepreneur_average_score || 0
bVal = String(bVal).toLowerCase() const bVal = b.tourist_average_score || b.entrepreneur_average_score || 0
return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
if (sortOrder.value === 'asc') {
return aVal.localeCompare(bVal)
} else {
return bVal.localeCompare(aVal)
} }
const aVal = (a.title || a.short_name || '').toLowerCase()
const bVal = (b.title || b.short_name || '').toLowerCase()
return sortOrder.value === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
}) })
return sorted return sorted
}) })
@@ -310,10 +188,9 @@ const paginatedObjects = computed(() => {
}) })
const totalPages = computed(() => { const totalPages = computed(() => {
return Math.ceil(filteredObjects.value.length / itemsPerPage) return Math.ceil(sortedObjects.value.length / itemsPerPage)
}) })
// Методы
const toggleQuickFilter = (type: string) => { const toggleQuickFilter = (type: string) => {
filters.value.type = filters.value.type === type ? '' : type filters.value.type = filters.value.type === type ? '' : type
applyFilters() applyFilters()
@@ -321,35 +198,44 @@ const toggleQuickFilter = (type: string) => {
const applyFilters = () => { const applyFilters = () => {
currentPage.value = 1 currentPage.value = 1
loadObjects()
} }
const resetFilters = () => { const resetFilters = () => {
filters.value = { filters.value = { q: '', type: '', maxPrice: null }
search: '',
type: '',
city: '',
maxPrice: null,
minRating: 0
}
currentPage.value = 1 currentPage.value = 1
loadObjects()
} }
const navigateToObject = (id: number) => { const navigateToObject = (id: number) => {
navigateTo(`/objects/${id}`) navigateTo(`/objects/${id}`)
} }
// Инициализация const loadObjects = async () => {
onMounted(async () => {
loading.value = true loading.value = true
await new Promise(resolve => setTimeout(resolve, 800)) try {
objects.value = mockObjects const response = await getList({
page: currentPage.value,
page_size: 50,
type: filters.value.type || undefined,
q: filters.value.q || undefined
})
let items = response.items
if (filters.value.maxPrice) {
items = items.filter(o => (o.price || 0) <= filters.value.maxPrice!)
}
objects.value = items
totalObjects.value = response.total
} catch (error) {
console.error('Error loading objects:', error)
objects.value = []
totalObjects.value = 0
} finally {
loading.value = false loading.value = false
}) }
}
// Следим за изменениями фильтров onMounted(loadObjects)
watch([filters, sortBy, sortOrder], () => {
applyFilters()
})
</script> </script>
<style scoped> <style scoped>
@@ -372,12 +258,14 @@ watch([filters, sortBy, sortOrder], () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-lg); gap: var(--space-xl);
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.header-text { .header-text {
flex: 1; display: flex;
flex-direction: column;
gap: var(--space-xs);
} }
.page-title { .page-title {
@@ -385,12 +273,13 @@ watch([filters, sortBy, sortOrder], () => {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: var(--font-bold); font-weight: var(--font-bold);
color: var(--text-primary); color: var(--text-primary);
margin-bottom: var(--space-xs); margin: 0;
} }
.page-subtitle { .page-subtitle {
font-size: var(--text-lg); font-size: var(--text-lg);
color: var(--text-secondary); color: var(--text-secondary);
margin: 0;
} }
.header-actions { .header-actions {
@@ -401,37 +290,36 @@ watch([filters, sortBy, sortOrder], () => {
.quick-filters { .quick-filters {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
flex-wrap: wrap;
} }
.quick-filter { .quick-filter {
padding: var(--space-sm) var(--space-md); padding: var(--space-xs) var(--space-md);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); border-radius: var(--radius-full);
background: var(--bg-primary); background: var(--bg-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary); color: var(--text-secondary);
} }
.quick-filter:hover { .quick-filter:hover,
border-color: var(--primary-300);
background: var(--primary-50);
transform: translateY(-1px);
}
.quick-filter.active { .quick-filter.active {
background: var(--primary-500); background: var(--primary-500);
color: var(--text-inverse); color: var(--text-inverse);
border-color: var(--primary-500); border-color: var(--primary-500);
} }
.search-filters {
padding: var(--space-lg);
margin-bottom: var(--space-lg);
}
.filter-grid { .filter-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-lg); gap: var(--space-lg);
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
@@ -439,7 +327,6 @@ watch([filters, sortBy, sortOrder], () => {
.filter-actions { .filter-actions {
display: flex; display: flex;
gap: var(--space-sm); gap: var(--space-sm);
flex-wrap: wrap;
} }
.view-controls { .view-controls {
@@ -447,16 +334,12 @@ watch([filters, sortBy, sortOrder], () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
} }
.sort-controls { .sort-controls {
display: flex; display: flex;
align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
align-items: center;
} }
.view-toggle { .view-toggle {
@@ -464,21 +347,27 @@ watch([filters, sortBy, sortOrder], () => {
gap: var(--space-xs); gap: var(--space-xs);
} }
.objects-grid.grid-view { .objects-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-lg); gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
.objects-grid.grid-view {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
} }
.objects-grid.list-view { .objects-grid.list-view {
display: flex; grid-template-columns: 1fr;
flex-direction: column;
gap: var(--space-md);
} }
.loading-state { .loading-state,
.empty-state {
text-align: center; text-align: center;
padding: var(--space-2xl); padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
} }
.loading-spinner { .loading-spinner {
@@ -496,18 +385,10 @@ watch([filters, sortBy, sortOrder], () => {
font-size: var(--text-lg); font-size: var(--text-lg);
} }
.empty-state {
text-align: center;
padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.empty-icon { .empty-icon {
font-size: 4rem; font-size: 4rem;
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
opacity: 0.5; opacity: 0.7;
} }
.empty-title { .empty-title {
@@ -527,97 +408,44 @@ watch([filters, sortBy, sortOrder], () => {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: var(--space-xs); gap: var(--space-xs);
margin-top: var(--space-xl);
} }
.pagination-btn { .pagination-btn {
padding: var(--space-sm) var(--space-md); padding: var(--space-xs) var(--space-md);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
font-weight: var(--font-medium);
} }
.pagination-btn.active,
.pagination-btn:hover { .pagination-btn:hover {
border-color: var(--primary-300);
background: var(--primary-50);
}
.pagination-btn.active {
background: var(--primary-500); background: var(--primary-500);
color: var(--text-inverse); color: var(--text-inverse);
border-color: var(--primary-500); border-color: var(--primary-500);
} }
.cursor-pointer:hover {
cursor: pointer;
}
@keyframes spin { @keyframes spin {
0% { 0% { transform: rotate(0deg); }
transform: rotate(0deg); 100% { transform: rotate(360deg); }
} }
100% {
transform: rotate(360deg);
}
}
/* Адаптивность */
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { .header-content {
flex-direction: column; flex-direction: column;
align-items: stretch;
} }
.header-actions { .header-actions {
justify-content: stretch; width: 100%;
}
.header-actions .btn {
flex: 1;
min-width: auto;
} }
.filter-grid { .filter-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.view-controls {
flex-direction: column;
gap: var(--space-md);
align-items: stretch;
}
.sort-controls {
justify-content: space-between;
}
.objects-grid.grid-view { .objects-grid.grid-view {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.page-title {
font-size: var(--text-2xl);
}
.quick-filters {
justify-content: center;
}
.quick-filter {
flex: 1;
min-width: 120px;
text-align: center;
}
}
</style> </style>
@@ -3,7 +3,6 @@
<main class="my-objects-page"> <main class="my-objects-page">
<div class="container"> <div class="container">
<!-- Заголовок -->
<div class="page-header"> <div class="page-header">
<div class="header-content"> <div class="header-content">
<div class="header-text"> <div class="header-text">
@@ -23,7 +22,6 @@
</div> </div>
</div> </div>
<!-- Фильтры -->
<div class="search-filters card"> <div class="search-filters card">
<div class="filter-grid"> <div class="filter-grid">
<div class="form-group"> <div class="form-group">
@@ -31,7 +29,10 @@
<select v-model="filters.status" class="form-select"> <select v-model="filters.status" class="form-select">
<option value="">Все статусы</option> <option value="">Все статусы</option>
<option value="active">Активные</option> <option value="active">Активные</option>
<option value="draft">Черновики</option>
<option value="moderation">На модерации</option>
<option value="inactive">Неактивные</option> <option value="inactive">Неактивные</option>
<option value="rejected">Отклонённые</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -46,23 +47,23 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Поиск</label> <label class="form-label">Поиск</label>
<input v-model="filters.search" type="text" class="form-input" placeholder="Название или город"> <input v-model="filters.q" type="text" class="form-input" placeholder="Название или адрес">
</div> </div>
</div> </div>
<div class="filter-actions"> <div class="filter-actions">
<button class="btn btn-primary" @click="applyFilters"> <button class="btn btn-primary" @click="applyFilters">Применить</button>
Применить <button class="btn btn-outline" @click="resetFilters">Сбросить</button>
</button>
<button class="btn btn-outline" @click="resetFilters">
Сбросить
</button>
</div> </div>
</div> </div>
<!-- Сетка карточек --> <div v-if="loading" class="loading-state">
<div class="loading-spinner"/>
<p class="loading-text">Загрузка объектов...</p>
</div>
<template v-else>
<div class="objects-grid"> <div class="objects-grid">
<!-- Карточка добавления нового объекта --> <div class="add-card" @click="navigateTo('/objects/create')">
<div class="add-card" @click="navigateToCreate">
<div class="add-card-content"> <div class="add-card-content">
<div class="add-icon"></div> <div class="add-icon"></div>
<h3 class="add-title">Добавить объект</h3> <h3 class="add-title">Добавить объект</h3>
@@ -70,47 +71,35 @@
</div> </div>
</div> </div>
<!-- Карточки объектов --> <div v-for="item in filteredObjects" :key="item.id" class="object-card">
<div v-for="object in myObjects" :key="object.id" class="object-card">
<div class="card-image"> <div class="card-image">
<img :src="object.image || '/images/placeholder.jpg'" :alt="object.title" > <img :src="'/images/placeholder.jpg'" :alt="item.title || item.short_name">
<div class="card-badge" :class="object.isActive ? 'badge-success' : 'badge-secondary'"> <div class="card-badge" :class="statusBadgeClass(item.status)">
{{ object.isActive ? 'Активен' : 'Неактивен' }} {{ statusLabel(item.status) }}
</div> </div>
</div> </div>
<div class="card-content"> <div class="card-content">
<h3 class="card-title">{{ object.title }}</h3> <h3 class="card-title">{{ item.title || item.short_name }}</h3>
<div class="card-meta"> <div class="card-meta">
<span class="card-type">{{ getTypeLabel(object.type) }}</span> <span class="card-type">{{ getTypeLabel(item.type) }}</span>
<span class="card-location">📍 {{ object.city }}</span>
</div> </div>
<p class="card-description">{{ truncateDescription(object.description) }}</p> <p class="card-description">{{ item.address || 'Адрес не указан' }}</p>
<div class="card-price">{{ formatPrice(object.price) }}</div> <div class="card-price">{{ formatPrice(item.price) }}</div>
<div class="card-date">Добавлен: {{ formatDate(object.createdAt) }}</div>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<!-- Кнопка просмотра объекта --> <NuxtLink :to="`/objects/${item.id}`" class="btn btn-outline btn-sm btn-with-icon view-btn">
<NuxtLink
:to="`/objects/${object.id}`"
class="btn btn-outline btn-sm btn-with-icon view-btn"
title="Просмотреть объект">
<span>👁</span> <span>👁</span>
Просмотр Просмотр
</NuxtLink> </NuxtLink>
<div class="action-buttons"> <div class="action-buttons">
<NuxtLink <NuxtLink :to="`/objects/${item.id}/edit`" class="btn btn-outline btn-sm btn-with-icon">
:to="`/objects/${object.id}/edit`"
class="btn btn-outline btn-sm btn-with-icon"
title="Редактировать">
<span></span> <span></span>
</NuxtLink> </NuxtLink>
<button <button
class="btn btn-outline btn-sm btn-with-icon delete-btn" class="btn btn-outline btn-sm btn-with-icon delete-btn"
title="Удалить" @click="deleteObject(item.id)">
@click="deleteObject(object.id)">
<span>🗑</span> <span>🗑</span>
</button> </button>
</div> </div>
@@ -118,24 +107,17 @@
</div> </div>
</div> </div>
<!-- Пустой state --> <div v-if="!loading && filteredObjects.length === 0" class="empty-state">
<div v-if="!loading && myObjects.length === 0" class="empty-state">
<div class="empty-icon">🏢</div> <div class="empty-icon">🏢</div>
<h3 class="empty-title">У вас пока нет объектов</h3> <h3 class="empty-title">У вас пока нет объектов</h3>
<p class="empty-description">Добавьте первый объект, чтобы начать работу</p> <p class="empty-description">Добавьте первый объект, чтобы начать работу</p>
<NuxtLink to="/objects/create" class="btn btn-primary"> <NuxtLink to="/objects/create" class="btn btn-primary">Добавить первый объект</NuxtLink>
Добавить первый объект
</NuxtLink>
</div> </div>
</template>
<!-- Навигация -->
<div class="page-navigation"> <div class="page-navigation">
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon"> <NuxtLink to="/objects" class="btn btn-outline btn-with-icon"> Все объекты</NuxtLink>
Все объекты <NuxtLink to="/" class="btn btn-outline btn-with-icon">🏠 На главную</NuxtLink>
</NuxtLink>
<NuxtLink to="/" class="btn btn-outline btn-with-icon">
🏠 На главную
</NuxtLink>
</div> </div>
</div> </div>
</main> </main>
@@ -144,130 +126,58 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface MyObjectItem { import type { ObjectShortResponse } from '~/types/objects'
id: number
title: string const { getMy, remove } = useObjects()
type: string
city: string const objects = ref<ObjectShortResponse[]>([])
price: number const loading = ref(true)
isActive: boolean
createdAt: string const filters = ref({ status: '', type: '', q: '' })
image?: string
description: string const filteredObjects = computed(() => {
let result = [...objects.value]
if (filters.value.status) {
result = result.filter(o => o.status === filters.value.status)
} }
if (filters.value.type) {
// Мок-данные с изображениями и описаниями result = result.filter(o => o.type === filters.value.type)
const mockMyObjects: MyObjectItem[] = [
{
id: 1,
title: 'Гостевой дом "У озера"',
type: 'guest_house',
city: 'Карелия',
price: 2800,
isActive: true,
createdAt: '2024-01-15',
image: '/images/objects/lake-house-1.jpg',
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты.'
},
{
id: 2,
title: 'Экскурсия по историческому центру',
type: 'tour',
city: 'Санкт-Петербург',
price: 1500,
isActive: true,
createdAt: '2024-01-10',
image: '/images/objects/city-tour.jpg',
description: 'Увлекательная пешеходная экскурсия по самым знаковым местам Северной столицы с опытным гидом.'
},
{
id: 3,
title: 'Горнолыжный курорт "Снежный"',
type: 'hotel',
city: 'Красная Поляна',
price: 5200,
isActive: false,
createdAt: '2024-01-05',
image: '/images/objects/ski-resort.jpg',
description: 'Современный горнолыжный комплекс с комфортабельными номерами и прямым доступом к склонам.'
},
{
id: 4,
title: 'Санаторий "Здоровье"',
type: 'sanatorium',
city: 'Кисловодск',
price: 3200,
isActive: true,
createdAt: '2024-01-03',
image: '/images/objects/sanatorium.jpg',
description: 'Лечебно-оздоровительный комплекс с минеральными водами и современным медицинским оборудованием.'
},
{
id: 5,
title: 'Тур по Золотому кольцу',
type: 'tour',
city: 'Москва',
price: 12500,
isActive: true,
createdAt: '2024-01-01',
image: '/images/objects/golden-ring.jpg',
description: '7-дневный автобусный тур по древним городам России с проживанием в комфортабельных отелях.'
} }
] if (filters.value.q) {
const q = filters.value.q.toLowerCase()
const myObjects = ref<MyObjectItem[]>(mockMyObjects) result = result.filter(o =>
const loading = ref(false) (o.title || o.short_name || '').toLowerCase().includes(q) ||
(o.address || '').toLowerCase().includes(q)
const filters = ref({ )
status: '', }
type: '', return result
search: ''
}) })
// Навигация const applyFilters = () => {}
const navigateToCreate = () => {
navigateTo('/objects/create')
}
// Методы
const applyFilters = () => {
let filtered = [...mockMyObjects]
if (filters.value.status) {
filtered = filtered.filter(obj =>
filters.value.status === 'active' ? obj.isActive : !obj.isActive
)
}
if (filters.value.type) {
filtered = filtered.filter(obj => obj.type === filters.value.type)
}
if (filters.value.search) {
const search = filters.value.search.toLowerCase()
filtered = filtered.filter(obj =>
obj.title.toLowerCase().includes(search) ||
obj.city.toLowerCase().includes(search) ||
obj.description.toLowerCase().includes(search)
)
}
myObjects.value = filtered
}
const resetFilters = () => { const resetFilters = () => {
filters.value = { filters.value = { status: '', type: '', q: '' }
status: '',
type: '',
search: ''
}
myObjects.value = mockMyObjects
} }
const deleteObject = async (id: number) => { const statusLabel = (status: string) => {
if (confirm('Вы уверены, что хотите удалить этот объект?')) { const labels: Record<string, string> = {
myObjects.value = myObjects.value.filter(obj => obj.id !== id) active: 'Активен',
draft: 'Черновик',
moderation: 'На модерации',
inactive: 'Неактивен',
rejected: 'Отклонён'
} }
return labels[status] || status
}
const statusBadgeClass = (status: string) => {
const classes: Record<string, string> = {
active: 'badge-success',
draft: 'badge-secondary',
moderation: 'badge-warning',
inactive: 'badge-secondary',
rejected: 'badge-error'
}
return classes[status] || 'badge-secondary'
} }
const getTypeLabel = (type: string) => { const getTypeLabel = (type: string) => {
@@ -281,12 +191,8 @@ const getTypeLabel = (type: string) => {
return types[type] || type return types[type] || type
} }
const truncateDescription = (description: string, maxLength: number = 100) => { const formatPrice = (price: number | undefined) => {
if (description.length <= maxLength) return description if (!price && price !== 0) return '—'
return description.substring(0, maxLength) + '...'
}
const formatPrice = (price: number) => {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', style: 'currency',
currency: 'RUB', currency: 'RUB',
@@ -294,9 +200,30 @@ const formatPrice = (price: number) => {
}).format(price) }).format(price)
} }
const formatDate = (dateString: string) => { const deleteObject = async (id: number) => {
return new Date(dateString).toLocaleDateString('ru-RU') if (confirm('Вы уверены, что хотите удалить этот объект?')) {
try {
await remove(id)
objects.value = objects.value.filter(o => o.id !== id)
} catch (error) {
console.error('Error deleting object:', error)
alert('Ошибка при удалении объекта')
} }
}
}
onMounted(async () => {
loading.value = true
try {
const response = await getMy()
objects.value = response.items
} catch (error) {
console.error('Error loading my objects:', error)
objects.value = []
} finally {
loading.value = false
}
})
</script> </script>
<style scoped> <style scoped>
@@ -319,11 +246,13 @@ const formatDate = (dateString: string) => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-lg); gap: var(--space-xl);
} }
.header-text { .header-text {
flex: 1; display: flex;
flex-direction: column;
gap: var(--space-xs);
} }
.page-title { .page-title {
@@ -331,12 +260,13 @@ const formatDate = (dateString: string) => {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: var(--font-bold); font-weight: var(--font-bold);
color: var(--text-primary); color: var(--text-primary);
margin-bottom: var(--space-xs); margin: 0;
} }
.page-subtitle { .page-subtitle {
font-size: var(--text-lg); font-size: var(--text-lg);
color: var(--text-secondary); color: var(--text-secondary);
margin: 0;
} }
.header-actions { .header-actions {
@@ -345,6 +275,11 @@ const formatDate = (dateString: string) => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.search-filters {
padding: var(--space-lg);
margin-bottom: var(--space-lg);
}
.filter-grid { .filter-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -355,92 +290,70 @@ const formatDate = (dateString: string) => {
.filter-actions { .filter-actions {
display: flex; display: flex;
gap: var(--space-sm); gap: var(--space-sm);
flex-wrap: wrap;
} }
/* Сетка карточек */
.objects-grid { .objects-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-lg); gap: var(--space-lg);
margin-bottom: var(--space-xl); margin-bottom: var(--space-xl);
} }
/* Карточка добавления */
.add-card { .add-card {
background: var(--bg-primary); border: 2px dashed var(--border-light);
border: 2px dashed var(--border-medium);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--space-xl); padding: var(--space-xl);
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 300px; min-height: 200px;
} }
.add-card:hover { .add-card:hover {
border-color: var(--primary-500); border-color: var(--primary-500);
background: var(--primary-50); background: var(--primary-50);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
} }
.add-card-content { .add-card-content {
display: flex; text-align: center;
flex-direction: column;
align-items: center;
gap: var(--space-md);
} }
.add-icon { .add-icon {
font-size: 3rem; font-size: 3rem;
opacity: 0.7; margin-bottom: var(--space-md);
transition: transform 0.3s ease;
}
.add-card:hover .add-icon {
transform: scale(1.1);
opacity: 1;
} }
.add-title { .add-title {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: var(--text-xl); font-size: var(--text-lg);
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin-bottom: var(--space-xs);
} }
.add-description { .add-description {
color: var(--text-secondary); color: var(--text-tertiary);
margin: 0;
font-size: var(--text-sm); font-size: var(--text-sm);
} }
/* Карточка объекта */
.object-card { .object-card {
background: var(--bg-primary); background: var(--bg-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
overflow: hidden; transition: box-shadow 0.3s ease;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
} }
.object-card:hover { .object-card:hover {
transform: translateY(-4px); box-shadow: var(--shadow-md);
box-shadow: var(--shadow-lg);
} }
.card-image { .card-image {
position: relative; position: relative;
height: 200px; height: 180px;
overflow: hidden; overflow: hidden;
} }
@@ -448,11 +361,6 @@ const formatDate = (dateString: string) => {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 0.3s ease;
}
.object-card:hover .card-image img {
transform: scale(1.05);
} }
.card-badge { .card-badge {
@@ -463,13 +371,11 @@ const formatDate = (dateString: string) => {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: var(--font-medium); font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
} }
.badge-success { .badge-success {
background: var(--success-50); background: var(--success-100);
color: var(--success-600); color: var(--success-700);
} }
.badge-secondary { .badge-secondary {
@@ -477,86 +383,62 @@ const formatDate = (dateString: string) => {
color: var(--gray-600); color: var(--gray-600);
} }
.badge-warning {
background: var(--warning-100);
color: var(--warning-700);
}
.badge-error {
background: var(--danger-100);
color: var(--danger-700);
}
.card-content { .card-content {
padding: var(--space-lg); padding: var(--space-lg);
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-sm);
} }
.card-title { .card-title {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: var(--text-xl); font-size: var(--text-lg);
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin-bottom: var(--space-sm);
line-height: 1.3;
} }
.card-meta { .card-meta {
display: flex; display: flex;
flex-direction: column; gap: var(--space-sm);
gap: var(--space-xs); margin-bottom: var(--space-sm);
} }
.card-type { .card-type {
font-size: var(--text-sm); padding: 2px var(--space-xs);
color: var(--primary-600); background: var(--bg-secondary);
font-weight: var(--font-medium); border-radius: var(--radius-sm);
} font-size: var(--text-xs);
color: var(--text-secondary);
.card-location {
font-size: var(--text-sm);
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: var(--space-xs);
} }
.card-description { .card-description {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-tertiary);
line-height: var(--leading-relaxed); margin-bottom: var(--space-md);
margin: var(--space-xs) 0; line-height: 1.4;
flex: 1;
} }
.card-price { .card-price {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: var(--text-2xl); font-size: var(--text-xl);
font-weight: var(--font-bold); font-weight: var(--font-bold);
color: var(--primary-600); color: var(--primary-600);
margin-top: auto;
}
.card-date {
font-size: var(--text-xs);
color: var(--text-tertiary);
} }
.card-actions { .card-actions {
padding: var(--space-lg); padding: var(--space-md) var(--space-lg);
padding-top: 0; border-top: 1px solid var(--border-light);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--space-sm);
}
.view-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
font-weight: var(--font-medium);
}
.view-btn:hover {
background: var(--primary-500);
color: var(--text-inverse);
border-color: var(--primary-500);
} }
.action-buttons { .action-buttons {
@@ -564,63 +446,62 @@ const formatDate = (dateString: string) => {
gap: var(--space-xs); gap: var(--space-xs);
} }
.delete-btn:hover { .delete-btn {
background: var(--error-50); color: var(--danger-500);
border-color: var(--error-300); border-color: var(--danger-200);
color: var(--error-600);
} }
/* Пустой state */ .delete-btn:hover {
background: var(--danger-50);
border-color: var(--danger-500);
}
.loading-state,
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: var(--space-2xl); padding: var(--space-2xl);
background: var(--bg-primary); background: var(--bg-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
margin: var(--space-xl) 0; }
.loading-spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-light);
border-top: 3px solid var(--primary-500);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
} }
.empty-icon { .empty-icon {
font-size: 4rem; font-size: 4rem;
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
opacity: 0.5; opacity: 0.7;
} }
.empty-title { .empty-title {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: var(--text-xl); font-size: var(--text-xl);
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
} }
.empty-description {
color: var(--text-secondary);
margin-bottom: var(--space-lg);
}
.page-navigation { .page-navigation {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: var(--space-xl); margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--border-light);
} }
/* Адаптивность */ @keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { .header-content {
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.header-actions {
justify-content: stretch;
}
.header-actions .btn {
flex: 1;
min-width: auto;
} }
.filter-grid { .filter-grid {
@@ -629,95 +510,11 @@ const formatDate = (dateString: string) => {
.objects-grid { .objects-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-md);
}
.card-actions {
flex-direction: column;
align-items: stretch;
}
.view-btn {
order: -1;
margin-bottom: var(--space-sm);
}
.action-buttons {
justify-content: space-between;
} }
.page-navigation { .page-navigation {
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
} }
.page-navigation .btn {
justify-content: center;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.page-title {
font-size: var(--text-2xl);
}
.objects-grid {
grid-template-columns: 1fr;
}
.add-card {
min-height: 250px;
padding: var(--space-lg);
}
.card-content {
padding: var(--space-md);
}
.card-actions {
padding: var(--space-md);
padding-top: 0;
}
}
/* Анимации для плавного появления */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.object-card {
animation: fadeInUp 0.5s ease forwards;
}
.object-card:nth-child(1) {
animation-delay: 0.1s;
}
.object-card:nth-child(2) {
animation-delay: 0.2s;
}
.object-card:nth-child(3) {
animation-delay: 0.3s;
}
.object-card:nth-child(4) {
animation-delay: 0.4s;
}
.object-card:nth-child(5) {
animation-delay: 0.5s;
} }
</style> </style>
@@ -0,0 +1,13 @@
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
total_pages: number
}
export interface ApiError {
message: string
error?: string
status?: number
}
@@ -1,4 +1,3 @@
// types/auth.ts
export interface LoginForm { export interface LoginForm {
email: string email: string
password: string password: string
@@ -12,26 +11,17 @@ export interface RegisterForm {
passwordConfirm: string passwordConfirm: string
} }
export interface User { export interface UserInfo {
id: number id: number
email: string email: string
first_name: string
last_name: string
full_name: string full_name: string
first_name?: string role: string
last_name?: string }
inn?: string | null
phone?: string | null export interface AuthResponse {
city?: string | null token: string
org_type?: string | null expires_at: string
org_full_name?: string | null user: UserInfo
org_short_name?: string | null
org_inn?: string | null
email_verified_at?: string | null
created_at: string
updated_at: string
// Статистика (если есть в API)
objects_count?: number
reviews_count?: number
active_objects_count?: number
moderation_objects_count?: number
} }
@@ -0,0 +1,111 @@
export interface ImageResponse {
id: number
object_id: number
url: string
is_primary: boolean
sort_order: number
}
export interface AmenityResponse {
id: number
name: string
category?: string
icon?: string
description?: string
}
export interface ObjectShortResponse {
id: number
title: string
short_name: string
long_name: string
type: string
price: number
price_period: string
address: string
is_active: boolean
is_verified: boolean
status: string
feedback_count: number
tourist_average_score?: number
entrepreneur_average_score?: number
}
export interface ObjectListResponse {
items: ObjectShortResponse[]
total: number
page: number
page_size: number
total_pages: number
}
export interface ObjectResponse {
id: number
created_at: string
updated_at: string
owner_id: number
owner?: object | null
title: string
short_name: string
long_name: string
type: string
price: number
price_period: string
phone: string
email: string
site: string
short_description: string
description: string
address: string
latitude: number
longitude: number
is_active: boolean
is_verified: boolean
status: string
view_count: number
feedback_count: number
images: ImageResponse[]
amenities: AmenityResponse[]
}
export interface CreateObjectRequest {
short_name: string
title?: string
long_name?: string
type?: string
price?: number
price_period?: string
phone?: string
email?: string
site?: string
short_description?: string
description?: string
address?: string
latitude?: number
longitude?: number
status?: string
is_active?: boolean | null
is_verified?: boolean | null
amenity_ids?: number[]
}
export interface UpdateObjectRequest {
title?: string | null
short_name?: string | null
long_name?: string | null
type?: string | null
price?: number | null
price_period?: string | null
phone?: string | null
email?: string | null
site?: string | null
short_description?: string | null
description?: string | null
address?: string | null
latitude?: number | null
longitude?: number | null
status?: string | null
is_active?: boolean | null
is_verified?: boolean | null
amenity_ids?: number[]
}
@@ -64,9 +64,9 @@ export default defineNuxtConfig({
'~/assets/css/main.css' '~/assets/css/main.css'
], ],
// Настройки для работы за прокси
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api/v1',
baseURL: process.env.BASE_URL || 'http://localhost:3000', baseURL: process.env.BASE_URL || 'http://localhost:3000',
telegramBotToken: process.env.NUXT_PUBLIC_TELEGRAM_BOT_TOKEN, telegramBotToken: process.env.NUXT_PUBLIC_TELEGRAM_BOT_TOKEN,
telegramChatId: process.env.NUXT_PUBLIC_TELEGRAM_CHAT_ID, telegramChatId: process.env.NUXT_PUBLIC_TELEGRAM_CHAT_ID,