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:
@@ -12,5 +12,3 @@ ALL_DOMAINS=yalarba.ru,www.yalarba.ru,valitovgaziz.ru,www.valitovgaziz.ru,easysi
|
||||
KEYCLOAK_ADMIN_PASSWORD=your_secure_password
|
||||
KEYCLOAK_DB_PASSWORD=your_secure_db_password
|
||||
|
||||
# API_ES port
|
||||
API_ES_APP_PORT=8088
|
||||
@@ -30,7 +30,7 @@
|
||||
│ Docker Compose Cluster │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_ES │ │
|
||||
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_YAL │ │
|
||||
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
|
||||
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │ │
|
||||
|
||||
@@ -165,21 +165,6 @@ restart_analytics:
|
||||
# Полный цикл обновления analytics
|
||||
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
|
||||
stop_cerbot:
|
||||
docker compose down certbot
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ sites:
|
||||
aliases: [www.easysite102.ru]
|
||||
type: container
|
||||
upstream: http://easysite:3000
|
||||
api: http://api_es:8088/
|
||||
api: http://api_yal:8787/
|
||||
|
||||
begushiybashkir:
|
||||
domain: begushiybashkir.ru
|
||||
@@ -165,7 +165,7 @@ sites:
|
||||
aliases: [www.easysite102.ru]
|
||||
type: container
|
||||
upstream: http://easysite:3000
|
||||
api: http://api_es:8088/
|
||||
api: http://api_yal:8787/
|
||||
|
||||
begushiybashkir:
|
||||
domain: begushiybashkir.ru
|
||||
|
||||
@@ -53,8 +53,6 @@ services:
|
||||
depends_on:
|
||||
easysite:
|
||||
condition: service_healthy
|
||||
api_es:
|
||||
condition: service_healthy
|
||||
certbot:
|
||||
condition: service_healthy
|
||||
api_tp:
|
||||
@@ -241,7 +239,7 @@ services:
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_es REST API app
|
||||
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_yal REST API app
|
||||
easysite:
|
||||
build:
|
||||
context: ./yalarba/easySite/easySite
|
||||
@@ -254,6 +252,7 @@ services:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
NUXT_PUBLIC_API_BASE: /api/v1
|
||||
networks:
|
||||
- web-network
|
||||
- app-network
|
||||
@@ -263,34 +262,6 @@ services:
|
||||
timeout: 10s
|
||||
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 сервиса
|
||||
api_yal:
|
||||
build:
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
│ • certbot - SSL сертификаты │
|
||||
│ • analytics - Статистика (Node.js) │
|
||||
│ • api_tp - API yalarba.ru (Go) │
|
||||
│ • api_es - API easysite102.ru (Go) │
|
||||
│ • api_yal - API easysite102.ru (Go) │
|
||||
│ • api_bb - API Бегущий Башкир (Go) │
|
||||
│ • easysite - SPA (Nuxt.js) │
|
||||
│ • db - PostgreSQL (yalarba/easy) │
|
||||
@@ -74,7 +74,7 @@
|
||||
|-------|-----|----------------|---------------|
|
||||
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/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` |
|
||||
| `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
```
|
||||
EMAIL=admin@example.com # Для Let's Encrypt
|
||||
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 |
|
||||
| analytics | `http://localhost:3000/health` | 3000 | 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 |
|
||||
| easysite | `http://localhost:3000/api/health` | 3000 | 30s |
|
||||
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
|
||||
|
||||
### Зависимости запуска
|
||||
Nginx запускается только после подтверждения здоровья:
|
||||
- `easysite`, `api_es`, `certbot`, `api_tp`, `api_bb`, `analytics`
|
||||
- `easysite`, `api_yal`, `certbot`, `api_tp`, `api_bb`, `analytics`
|
||||
|
||||
## Волумы
|
||||
|
||||
|
||||
@@ -231,82 +231,28 @@ server {
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: API Backend для Easysite
|
||||
# ЛОКАЦИЯ: API Backend для Easysite (api_yal)
|
||||
# ============================================
|
||||
location /api/ {
|
||||
# Отдельный API endpoint для backend
|
||||
proxy_pass http://api_es:8088/;
|
||||
location /api/v1/ {
|
||||
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 из запроса
|
||||
if ($request_method = OPTIONS) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 от паник
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.UpdateHistory{},
|
||||
&models.Object{},
|
||||
&models.ObjectImage{},
|
||||
&models.Amenity{},
|
||||
&models.RatingVote{},
|
||||
&models.VoteBreakdown{},
|
||||
&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 обрабатывает ошибки валидации
|
||||
func (h *AuthHandler) handleValidationError(w http.ResponseWriter, err error) {
|
||||
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("/change-password", handler.RequestPasswordReset)
|
||||
r.Get("/me", handler.GetMe)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type AuthService interface {
|
||||
Logout(userID uint) error
|
||||
ValidateAccessToken(tokenString string) (*jwt.MapClaims, error)
|
||||
GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error)
|
||||
GetUserFromID(userID uint) (*models.Account, error)
|
||||
|
||||
// Reset password methods
|
||||
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)
|
||||
}
|
||||
|
||||
// GetUserFromID получает пользователя по ID
|
||||
func (s *authServiceImpl) GetUserFromID(userID uint) (*models.Account, error) {
|
||||
return s.accountRepo.GetByID(userID)
|
||||
}
|
||||
|
||||
// generateAccessToken генерирует access token
|
||||
func (s *authServiceImpl) generateAccessToken(account *models.Account) (string, time.Time, error) {
|
||||
expiresAt := time.Now().Add(s.accessTokenTTL)
|
||||
|
||||
@@ -12,9 +12,12 @@ import (
|
||||
// CreateObjectRequest - DTO для создания объекта
|
||||
type CreateObjectRequest struct {
|
||||
OwnerID uint `json:"owner_id" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
ShortName string `json:"short_name" binding:"required,min=1,max=255"`
|
||||
LongName string `json:"long_name"`
|
||||
Type string `json:"type"`
|
||||
Price float64 `json:"price"`
|
||||
PricePeriod string `json:"price_period"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Site string `json:"site" binding:"omitempty,url"`
|
||||
@@ -23,15 +26,20 @@ type CreateObjectRequest struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude" binding:"omitempty,latitude"`
|
||||
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"`
|
||||
AmenityIDs []uint `json:"amenity_ids"`
|
||||
}
|
||||
|
||||
// UpdateObjectRequest - DTO для обновления объекта (все поля опциональны)
|
||||
type UpdateObjectRequest struct {
|
||||
Title *string `json:"title"`
|
||||
ShortName *string `json:"short_name" binding:"omitempty,min=1,max=255"`
|
||||
LongName *string `json:"long_name"`
|
||||
Type *string `json:"type"`
|
||||
Price *float64 `json:"price"`
|
||||
PricePeriod *string `json:"price_period"`
|
||||
Phone *string `json:"phone"`
|
||||
Email *string `json:"email" binding:"omitempty,email"`
|
||||
Site *string `json:"site" binding:"omitempty,url"`
|
||||
@@ -40,8 +48,10 @@ type UpdateObjectRequest struct {
|
||||
Address *string `json:"address"`
|
||||
Latitude *float64 `json:"latitude" binding:"omitempty,latitude"`
|
||||
Longitude *float64 `json:"longitude" binding:"omitempty,longitude"`
|
||||
Status *string `json:"status"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
IsVerified *bool `json:"is_verified"`
|
||||
AmenityIDs []uint `json:"amenity_ids"`
|
||||
}
|
||||
|
||||
// ObjectResponse - DTO для полного ответа с объектом (включая связанные данные)
|
||||
@@ -52,9 +62,12 @@ type ObjectResponse struct {
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
OwnerID uint `json:"owner_id"`
|
||||
Owner *account.AccountResponse `json:"owner,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ShortName string `json:"short_name"`
|
||||
LongName string `json:"long_name"`
|
||||
Type string `json:"type"`
|
||||
Price float64 `json:"price"`
|
||||
PricePeriod string `json:"price_period"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Site string `json:"site"`
|
||||
@@ -65,27 +78,53 @@ type ObjectResponse struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Status string `json:"status"`
|
||||
ViewCount int `json:"view_count"`
|
||||
FeedbackCount int `json:"feedback_count"`
|
||||
TouristRating *RatingResponse `json:"tourist_rating,omitempty"`
|
||||
EntrepreneurRating *RatingResponse `json:"entrepreneur_rating,omitempty"`
|
||||
Feedbacks []FeedbackShortResponse `json:"feedbacks,omitempty"`
|
||||
Images []ImageResponse `json:"images,omitempty"`
|
||||
Amenities []AmenityResponse `json:"amenities,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectShortResponse - DTO для краткого ответа (списки, вложенные данные)
|
||||
type ObjectShortResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ShortName string `json:"short_name"`
|
||||
LongName string `json:"long_name"`
|
||||
Type string `json:"type"`
|
||||
Price float64 `json:"price"`
|
||||
PricePeriod string `json:"price_period"`
|
||||
Address string `json:"address"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Status string `json:"status"`
|
||||
FeedbackCount int `json:"feedback_count"`
|
||||
// Агрегированные рейтинги для списка
|
||||
TouristAverageScore float64 `json:"tourist_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 для списка объектов с пагинацией
|
||||
type ObjectListResponse struct {
|
||||
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),
|
||||
Type: r.URL.Query().Get("type"),
|
||||
Query: r.URL.Query().Get("q"),
|
||||
ObjectStatus: r.URL.Query().Get("status"),
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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}
|
||||
func (h *ObjectHandler) GetObjectsByOwner(w http.ResponseWriter, r *http.Request) {
|
||||
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.Use(middleware.AuthMiddleware(jwtSecret))
|
||||
|
||||
// Мои объекты
|
||||
r.Get("/objects/my", objectHandler.GetMyObjects)
|
||||
|
||||
// CRUD для объектов
|
||||
r.Post("/objects", objectHandler.CreateObject)
|
||||
r.Put("/objects/{id}", objectHandler.UpdateObject)
|
||||
|
||||
@@ -83,11 +83,23 @@ func (s *objectServiceImpl) CreateObject(ctx context.Context, req *CreateObjectR
|
||||
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{
|
||||
OwnerID: req.OwnerID,
|
||||
Title: title,
|
||||
ShortName: req.ShortName,
|
||||
LongName: req.LongName,
|
||||
Type: req.Type,
|
||||
Price: req.Price,
|
||||
PricePeriod: req.PricePeriod,
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
Site: req.Site,
|
||||
@@ -98,6 +110,8 @@ func (s *objectServiceImpl) CreateObject(ctx context.Context, req *CreateObjectR
|
||||
Longitude: req.Longitude,
|
||||
IsActive: isActive,
|
||||
IsVerified: isVerified,
|
||||
Status: status,
|
||||
ViewCount: 0,
|
||||
FeedbackCount: 0,
|
||||
}
|
||||
|
||||
@@ -173,6 +187,11 @@ func (s *objectServiceImpl) ListObjects(ctx context.Context, req *ListObjectsReq
|
||||
|
||||
// Применяем фильтры
|
||||
switch {
|
||||
case req.ObjectStatus != "":
|
||||
objects, err = s.objectRepository.ListByObjectStatus(req.ObjectStatus, offset, pageSize)
|
||||
if err == nil {
|
||||
total, _ = s.countObjectsByStatusString(req.ObjectStatus)
|
||||
}
|
||||
case req.Type != "":
|
||||
objects, err = s.objectRepository.ListByType(req.Type, offset, pageSize)
|
||||
if err == nil {
|
||||
@@ -553,6 +572,9 @@ func (s *objectServiceImpl) validateCreateRequest(req *CreateObjectRequest) erro
|
||||
}
|
||||
|
||||
func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjectRequest) {
|
||||
if req.Title != nil {
|
||||
object.Title = *req.Title
|
||||
}
|
||||
if req.ShortName != nil {
|
||||
object.ShortName = *req.ShortName
|
||||
}
|
||||
@@ -562,6 +584,12 @@ func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjec
|
||||
if req.Type != nil {
|
||||
object.Type = *req.Type
|
||||
}
|
||||
if req.Price != nil {
|
||||
object.Price = *req.Price
|
||||
}
|
||||
if req.PricePeriod != nil {
|
||||
object.PricePeriod = *req.PricePeriod
|
||||
}
|
||||
if req.Phone != nil {
|
||||
object.Phone = *req.Phone
|
||||
}
|
||||
@@ -586,6 +614,9 @@ func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjec
|
||||
if req.Longitude != nil {
|
||||
object.Longitude = *req.Longitude
|
||||
}
|
||||
if req.Status != nil {
|
||||
object.Status = models.ObjectStatus(*req.Status)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
object.IsActive = *req.IsActive
|
||||
}
|
||||
@@ -610,9 +641,12 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
|
||||
CreatedAt: object.CreatedAt,
|
||||
UpdatedAt: object.UpdatedAt,
|
||||
OwnerID: object.OwnerID,
|
||||
Title: object.Title,
|
||||
ShortName: object.ShortName,
|
||||
LongName: object.LongName,
|
||||
Type: object.Type,
|
||||
Price: object.Price,
|
||||
PricePeriod: object.PricePeriod,
|
||||
Phone: object.Phone,
|
||||
Email: object.Email,
|
||||
Site: object.Site,
|
||||
@@ -623,6 +657,8 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
|
||||
Longitude: object.Longitude,
|
||||
IsActive: object.IsActive,
|
||||
IsVerified: object.IsVerified,
|
||||
Status: string(object.Status),
|
||||
ViewCount: object.ViewCount,
|
||||
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
|
||||
}
|
||||
|
||||
func (s *objectServiceImpl) mapToObjectShortResponse(object *models.Object) ObjectShortResponse {
|
||||
return ObjectShortResponse{
|
||||
ID: object.ID,
|
||||
Title: object.Title,
|
||||
ShortName: object.ShortName,
|
||||
LongName: object.LongName,
|
||||
Type: object.Type,
|
||||
Price: object.Price,
|
||||
PricePeriod: object.PricePeriod,
|
||||
Address: object.Address,
|
||||
IsActive: object.IsActive,
|
||||
IsVerified: object.IsVerified,
|
||||
Status: string(object.Status),
|
||||
FeedbackCount: object.FeedbackCount,
|
||||
}
|
||||
}
|
||||
@@ -729,3 +795,7 @@ func (s *objectServiceImpl) countObjectsBySearch(query string) (int64, error) {
|
||||
// TODO: Добавить метод CountBySearch в репозиторий
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *objectServiceImpl) countObjectsByStatusString(status string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package object
|
||||
|
||||
// ListObjectsRequest параметры для получения списка объектов
|
||||
type ListObjectsRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Type string
|
||||
Status *bool
|
||||
Query string
|
||||
Page int
|
||||
PageSize int
|
||||
Type string
|
||||
Status *bool
|
||||
ObjectStatus string
|
||||
Query string
|
||||
}
|
||||
|
||||
// FeedbackListResponse ответ со списком отзывов
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
type ObjectStatus string
|
||||
|
||||
const (
|
||||
ObjectStatusDraft ObjectStatus = "draft"
|
||||
ObjectStatusModeration ObjectStatus = "moderation"
|
||||
ObjectStatusActive ObjectStatus = "active"
|
||||
ObjectStatusInactive ObjectStatus = "inactive"
|
||||
ObjectStatusRejected ObjectStatus = "rejected"
|
||||
)
|
||||
|
||||
type Object struct {
|
||||
/*ID, CreatedAt, UpdatedAt, DeletedAt (Update's history)*/
|
||||
Base `gorm:"embedded"`
|
||||
@@ -11,12 +21,14 @@ type Object struct {
|
||||
Owner Account `gorm:"foreignKey:OwnerID;references:ID" json:"owner"`
|
||||
|
||||
// Основная информация
|
||||
// короткое название
|
||||
Title string `gorm:"default:''" json:"title"`
|
||||
ShortName string `gorm:"not null" json:"short_name"`
|
||||
// длинное название
|
||||
LongName string `json:"long_name"`
|
||||
// тип места отдыха
|
||||
Type string `json:"type"`
|
||||
LongName string `json:"long_name"`
|
||||
Type string `json:"type"`
|
||||
|
||||
// Цена
|
||||
Price float64 `gorm:"default:0" json:"price"`
|
||||
PricePeriod string `gorm:"default:'per_unit'" json:"price_period"`
|
||||
|
||||
// контактные данные
|
||||
Phone string `json:"phone"`
|
||||
@@ -35,8 +47,10 @@ type Object struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
|
||||
// Статус объекта
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsVerified bool `gorm:"default:false" json:"is_verified"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
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"`
|
||||
@@ -48,4 +62,26 @@ type Object struct {
|
||||
// Связи с отзывами
|
||||
Feedbacks []Feedback `gorm:"foreignKey:ObjectID" json:"feedbacks,omitempty"`
|
||||
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(isActive bool, offset, limit int) ([]models.Object, error)
|
||||
|
||||
// ListByObjectStatus возвращает объекты по статусу объекта (draft, active, etc.)
|
||||
ListByObjectStatus(status string, offset, limit int) ([]models.Object, error)
|
||||
|
||||
// Search находит объекты по названию, типу или адресу
|
||||
Search(query string, offset, limit int) ([]models.Object, error)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func (r *objectRepositoryImpl) Create(object *models.Object) error {
|
||||
// GetByID возвращает объект по ID
|
||||
func (r *objectRepositoryImpl) GetByID(id uint) (*models.Object, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (r *objectRepositoryImpl) Delete(id uint) error {
|
||||
// List возвращает список объектов с пагинацией
|
||||
func (r *objectRepositoryImpl) List(offset, limit int) ([]models.Object, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (r *objectRepositoryImpl) Count() (int64, error) {
|
||||
// ListByOwner возвращает объекты по владельцу
|
||||
func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]models.Object, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]m
|
||||
// ListByType возвращает объекты по типу
|
||||
func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int) ([]models.Object, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,7 +80,17 @@ func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int)
|
||||
// ListByStatus возвращает объекты по статусу
|
||||
func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([]models.Object, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,7 +100,7 @@ func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([
|
||||
// Search находит объекты по названию, типу или адресу
|
||||
func (r *objectRepositoryImpl) Search(query string, offset, limit int) ([]models.Object, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package router
|
||||
import (
|
||||
"api_yal/internal/config"
|
||||
"api_yal/internal/domain/account"
|
||||
"api_yal/internal/domain/amenity"
|
||||
"api_yal/internal/domain/appeal"
|
||||
"api_yal/internal/domain/auth"
|
||||
"api_yal/internal/domain/comment"
|
||||
"api_yal/internal/domain/feetback"
|
||||
"api_yal/internal/domain/object"
|
||||
"api_yal/internal/domain/rating"
|
||||
"api_yal/internal/domain/upload"
|
||||
"api_yal/internal/logger"
|
||||
"time"
|
||||
|
||||
@@ -74,6 +76,12 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
|
||||
// Регистрируем маршруты обращений
|
||||
appeal.RegisterRoutes(r, db, config.JWTSecret)
|
||||
|
||||
// Регистрируем маршруты для удобств
|
||||
amenity.RegisterRoutes(r, db, config.JWTSecret)
|
||||
|
||||
// Регистрируем маршруты для загрузки файлов
|
||||
upload.RegisterRoutes(r, db, config.JWTSecret, config.UploadPath)
|
||||
|
||||
})
|
||||
|
||||
zapLogger.Info("Настройка маршрутов завершена")
|
||||
@@ -106,9 +114,6 @@ func addProductionMiddleware(r *chi.Mux, config *config.Config) {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Content-Type проверка
|
||||
r.Use(ChiMiddleware.AllowContentType("application/json", "application/xml"))
|
||||
|
||||
// Rate limiting
|
||||
if config.RateLimit.Enabled {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
1. **easysite** – Nuxt.js приложение (easysite102.ru)
|
||||
2. **yalarba** – Vue.js SPA приложение (yalarba.ru)
|
||||
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)
|
||||
6. **db, db_bb** – PostgreSQL базы данных
|
||||
7. **nginx** – Веб-сервер с reverse proxy и SSL
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
```
|
||||
Турист (yalarba.ru) → API_TP → БД (поиск, отзывы, маршруты)
|
||||
Владелец (easysite102.ru) → API_ES → БД (добавление объектов, управление)
|
||||
Владелец (easysite102.ru) → api_yal → БД (добавление объектов, управление)
|
||||
Администратор → Nginx + аналитика (мониторинг, логи)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,83 +1,91 @@
|
||||
<!-- components/ObjectCard.vue -->
|
||||
<template>
|
||||
<div class="card cursor-pointer" @click="$emit('click')">
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="object.image"
|
||||
<img
|
||||
:src="imageSrc"
|
||||
:alt="object.title"
|
||||
class="w-full h-48 object-cover"
|
||||
>
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="badge badge-primary">
|
||||
{{ getTypeLabel(object.type) }}
|
||||
<span class="badge" :class="statusBadgeClass">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
{{ object.description }}
|
||||
{{ object.address || 'Адрес не указан' }}
|
||||
</p>
|
||||
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1">
|
||||
<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 class="text-right">
|
||||
<div class="font-bold text-primary-600">
|
||||
{{ formatPrice(object.price) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">за ночь</div>
|
||||
<div class="text-xs text-gray-500">{{ object.price_period || 'за единицу' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-3 flex items-center text-sm text-gray-500">
|
||||
<span class="mr-2">📍</span>
|
||||
<span>{{ object.city }}</span>
|
||||
<span>{{ object.address || 'Адрес не указан' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ObjectItem {
|
||||
id: number
|
||||
title: string
|
||||
type: string
|
||||
city: string
|
||||
price: number
|
||||
rating: number
|
||||
image: string
|
||||
description: string
|
||||
}
|
||||
import type { ObjectShortResponse } from '~/types/objects'
|
||||
|
||||
interface Props {
|
||||
object: ObjectItem
|
||||
object: ObjectShortResponse
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<{ click: [] }>()
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const types: Record<string, string> = {
|
||||
hotel: 'Отель',
|
||||
sanatorium: 'Санаторий',
|
||||
guest_house: 'Гостевой дом',
|
||||
tour: 'Тур',
|
||||
excursion: 'Экскурсия'
|
||||
const imageSrc = computed(() => {
|
||||
return '/images/placeholder.jpg'
|
||||
})
|
||||
|
||||
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 types[type] || type
|
||||
}
|
||||
return labels[props.object.status] || props.object.status
|
||||
})
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
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', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<!-- components/ObjectForm.vue -->
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<!-- Основная информация -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold">Основная информация</h3>
|
||||
@@ -11,12 +9,22 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">Название объекта *</label>
|
||||
<input
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
class="form-input"
|
||||
v-model="formData.short_name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
required
|
||||
placeholder="Введите название">
|
||||
placeholder="Короткое название">
|
||||
</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">
|
||||
<label class="form-label">Тип объекта *</label>
|
||||
<select v-model="formData.type" class="form-select" required>
|
||||
@@ -29,91 +37,95 @@
|
||||
<option value="restaurant">Ресторан</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
v-model="formData.description" class="form-input" rows="4" required
|
||||
v-model="formData.description" class="form-input" rows="4"
|
||||
placeholder="Подробное описание объекта"/>
|
||||
</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 class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold">Местоположение</h3>
|
||||
</div>
|
||||
<div class="card-body space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group">
|
||||
<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
|
||||
v-model="formData.address" type="text" class="form-input" required
|
||||
placeholder="Полный адрес">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Адрес</label>
|
||||
<input
|
||||
v-model="formData.address" type="text" class="form-input"
|
||||
placeholder="Полный адрес">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Цены и контакты -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold">Цены и контакты</h3>
|
||||
</div>
|
||||
<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="form-group">
|
||||
<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>
|
||||
<label class="form-label">Телефон</label>
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input
|
||||
v-model="formData.contact.email" type="email" class="form-input"
|
||||
placeholder="email@example.com">
|
||||
</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">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input
|
||||
v-model="formData.amenities" type="checkbox" :value="amenity"
|
||||
class="rounded border-gray-300">
|
||||
<span class="text-sm">{{ amenity }}</span>
|
||||
</label>
|
||||
v-model="formData.email" type="email" class="form-input"
|
||||
placeholder="email@example.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий -->
|
||||
<div class="flex gap-4 justify-end">
|
||||
<button type="button" class="btn btn-outline" :disabled="loading" @click="$emit('cancel')">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
<span v-if="loading">Сохранение...</span>
|
||||
<span v-else>{{ props.object ? 'Обновить' : 'Создать' }}</span>
|
||||
<span v-else>{{ object ? 'Обновить' : 'Создать' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -121,18 +133,18 @@ v-model="formData.amenities" type="checkbox" :value="amenity"
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ObjectFormData {
|
||||
title: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
type: string
|
||||
description: string
|
||||
city: string
|
||||
short_description: string
|
||||
address: string
|
||||
price: number
|
||||
images: string[]
|
||||
amenities: string[]
|
||||
contact: {
|
||||
phone: string
|
||||
email: string
|
||||
}
|
||||
price: number | null
|
||||
price_period: string
|
||||
phone: string
|
||||
email: string
|
||||
site: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -150,27 +162,21 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const availableAmenities = [
|
||||
'Wi-Fi', 'Парковка', 'Бассейн', 'СПА', 'Завтрак',
|
||||
'Кондиционер', 'Трансфер', 'Экскурсии', 'Баня', 'Ресторан'
|
||||
]
|
||||
|
||||
const formData = reactive<ObjectFormData>({
|
||||
title: '',
|
||||
short_name: '',
|
||||
long_name: '',
|
||||
type: '',
|
||||
description: '',
|
||||
city: '',
|
||||
short_description: '',
|
||||
address: '',
|
||||
price: 0,
|
||||
images: [],
|
||||
amenities: [],
|
||||
contact: {
|
||||
phone: '',
|
||||
email: ''
|
||||
}
|
||||
price: null,
|
||||
price_period: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
site: '',
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
// Заполнение формы данными при редактировании
|
||||
watch(() => props.object, (newObject) => {
|
||||
if (newObject) {
|
||||
Object.assign(formData, newObject)
|
||||
@@ -178,6 +184,11 @@ watch(() => props.object, (newObject) => {
|
||||
}, { immediate: true })
|
||||
|
||||
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 { User, LoginForm, RegisterForm } from '~/types/auth'
|
||||
import type { UserInfo, LoginForm, RegisterForm, AuthResponse } from '~/types/auth'
|
||||
|
||||
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 loading = ref(false)
|
||||
|
||||
const login = async (credentials: LoginForm) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<{ user: User; token: string }>(
|
||||
'https://easysite102.ru/api/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: credentials
|
||||
}
|
||||
)
|
||||
const response = await $fetch<AuthResponse>(`${apiBase}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: credentials
|
||||
})
|
||||
|
||||
user.value = response.user
|
||||
// Сохраняем токен в localStorage или cookies
|
||||
localStorage.setItem('auth_token', response.token)
|
||||
|
||||
return response
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
@@ -34,21 +31,16 @@ export const useAuth = () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { passwordConfirm, ...registerData } = userData
|
||||
const full_name = `${userData.first_name} ${userData.last_name}`
|
||||
|
||||
const response = await $fetch<{ user: User }>(
|
||||
'https://easysite102.ru/api/auth/register',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
...registerData,
|
||||
full_name
|
||||
}
|
||||
}
|
||||
)
|
||||
const response = await $fetch<AuthResponse>(`${apiBase}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: registerData
|
||||
})
|
||||
|
||||
user.value = response.user
|
||||
localStorage.setItem('auth_token', response.token)
|
||||
|
||||
return response
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
@@ -57,9 +49,11 @@ export const useAuth = () => {
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
try {
|
||||
await $fetch('https://easysite102.ru/api/auth/logout', {
|
||||
method: 'POST'
|
||||
await $fetch(`${apiBase}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
@@ -75,14 +69,9 @@ export const useAuth = () => {
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ user: User }>(
|
||||
'https://easysite102.ru/api/auth/me',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
const response = await $fetch<{ user: UserInfo }>(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
user.value = response.user
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
@@ -99,4 +88,4 @@ export const useAuth = () => {
|
||||
logout,
|
||||
checkAuth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<div class="container max-w-4xl">
|
||||
<!-- Заголовок -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-main">
|
||||
@@ -16,21 +15,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Загрузка -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner"/>
|
||||
<p class="loading-text">Загрузка данных объекта...</p>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<ObjectForm
|
||||
v-else-if="object"
|
||||
:object="object"
|
||||
:loading="updating"
|
||||
<ObjectForm
|
||||
v-else-if="object"
|
||||
:object="object"
|
||||
:loading="updating"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel" />
|
||||
|
||||
<!-- Объект не найден -->
|
||||
<div v-else class="error-state">
|
||||
<div class="error-icon">❌</div>
|
||||
<h3 class="error-title">Объект не найден</h3>
|
||||
@@ -51,70 +47,46 @@
|
||||
</template>
|
||||
|
||||
<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 object = ref<ObjectData | null>(null)
|
||||
const { getById, update } = useObjects()
|
||||
|
||||
const object = ref<Record<string, unknown> | null>(null)
|
||||
const loading = ref(true)
|
||||
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 () => {
|
||||
// Имитация загрузки данных
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
object.value = mockObject
|
||||
loading.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
const data = await getById(id)
|
||||
object.value = {
|
||||
short_name: data.short_name,
|
||||
long_name: data.long_name,
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSubmit = async (formData: any) => {
|
||||
const handleSubmit = async (formData: Record<string, unknown>) => {
|
||||
updating.value = true
|
||||
|
||||
try {
|
||||
// Имитация обновления
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
console.log('Обновление объекта:', {
|
||||
id: parseInt(route.params.id as string),
|
||||
...formData
|
||||
})
|
||||
|
||||
alert('Объект успешно обновлен!')
|
||||
await navigateTo(`/objects/${route.params.id}`)
|
||||
const id = parseInt(route.params.id as string)
|
||||
await update(id, formData as Parameters<typeof update>[1])
|
||||
await navigateTo(`/objects/${id}`)
|
||||
} catch (error) {
|
||||
console.error('Error updating object:', error)
|
||||
alert('Ошибка при обновлении объекта')
|
||||
@@ -124,8 +96,7 @@ const handleSubmit = async (formData: any) => {
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
const objectId = route.params.id
|
||||
navigateTo(`/objects/${objectId}`)
|
||||
navigateTo(`/objects/${route.params.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -232,16 +203,10 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.header-main {
|
||||
flex-direction: column;
|
||||
@@ -257,19 +222,5 @@ const handleCancel = () => {
|
||||
flex-direction: column;
|
||||
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,397 +3,276 @@
|
||||
|
||||
<main class="object-page">
|
||||
<div class="container max-w-6xl">
|
||||
<!-- Хлебные крошки -->
|
||||
<nav class="breadcrumbs">
|
||||
<NuxtLink to="/objects" class="breadcrumb-link">Все объекты</NuxtLink>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="breadcrumb-current">{{ object?.title }}</span>
|
||||
<span class="breadcrumb-current">{{ object?.title || object?.short_name }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Заголовок и действия -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<div class="object-meta">
|
||||
<span class="object-type">{{ getTypeLabel(object?.type) }}</span>
|
||||
<div class="rating">
|
||||
<div class="rating-stars">
|
||||
<span
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="rating-star"
|
||||
:class="{ empty: star > Math.round(object?.rating || 0) }">
|
||||
★
|
||||
</span>
|
||||
</div>
|
||||
<span class="rating-value">{{ object?.rating }}</span>
|
||||
<span class="reviews-count">({{ object?.reviewsCount }} отзывов)</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="page-title">{{ object?.title }}</h1>
|
||||
<div class="object-location">
|
||||
<span class="location-icon">📍</span>
|
||||
{{ object?.city }}, {{ object?.address }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="price-section">
|
||||
<div class="price">{{ formatPrice(object?.price) }}</div>
|
||||
<div class="price-period">за ночь</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary btn-large" @click="showBookingModal = true">
|
||||
Забронировать
|
||||
</button>
|
||||
<button class="btn btn-outline btn-with-icon">
|
||||
<span>❤️</span>
|
||||
В избранное
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner"/>
|
||||
<p class="loading-text">Загрузка объекта...</p>
|
||||
</div>
|
||||
|
||||
<!-- Галерея изображений -->
|
||||
<div class="gallery-section">
|
||||
<div class="main-image">
|
||||
<img :src="object?.images[0]" :alt="object?.title" class="gallery-image" @click="openGallery(0)" >
|
||||
</div>
|
||||
<div v-if="object?.images && object.images.length > 1" class="thumbnails">
|
||||
<div
|
||||
v-for="(image, index) in object.images.slice(1, 5)"
|
||||
:key="index" class="thumbnail"
|
||||
@click="openGallery(index + 1)">
|
||||
<img :src="image" :alt="`${object.title} - фото ${index + 2}`">
|
||||
<div v-if="index === 3 && object.images.length > 5" class="more-images">
|
||||
+{{ object.images.length - 5 }}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<div class="content-grid">
|
||||
<!-- Информация об объекте -->
|
||||
<div class="content-main">
|
||||
<!-- Описание -->
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Описание</h2>
|
||||
<p class="object-description">{{ object?.description }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Удобства -->
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Удобства</h2>
|
||||
<div class="amenities-grid">
|
||||
<div v-for="amenity in object?.amenities" :key="amenity" class="amenity-item">
|
||||
<span class="amenity-icon">✅</span>
|
||||
<span class="amenity-text">{{ amenity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Контакты -->
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Контакты</h2>
|
||||
<div class="contact-info">
|
||||
<div class="contact-item">
|
||||
<span class="contact-icon">📞</span>
|
||||
<a :href="`tel:${object?.contact.phone}`" class="contact-link">
|
||||
{{ object?.contact.phone }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<span class="contact-icon">✉️</span>
|
||||
<a :href="`mailto:${object?.contact.email}`" class="contact-link">
|
||||
{{ object?.contact.email }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<span class="contact-icon">📍</span>
|
||||
<span class="contact-text">{{ object?.address }}, {{ object?.city }}</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"
|
||||
<template v-else>
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<div class="object-meta">
|
||||
<span class="object-type">{{ getTypeLabel(object.type) }}</span>
|
||||
<div class="rating">
|
||||
<div class="rating-stars">
|
||||
<span
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="rating-star"
|
||||
:class="{ empty: star > Math.round(object?.rating || 0) }">
|
||||
:class="{ empty: star > Math.round(touristScore) }">
|
||||
★
|
||||
</span>
|
||||
</div>
|
||||
<div class="reviews-count">{{ object?.reviewsCount }} отзывов</div>
|
||||
<span class="rating-value">{{ touristScore }}</span>
|
||||
<span class="reviews-count">({{ object.feedback_count }} отзывов)</span>
|
||||
</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>
|
||||
<h1 class="page-title">{{ object.title || object.short_name }}</h1>
|
||||
<div class="object-location">
|
||||
<span class="location-icon">📍</span>
|
||||
{{ object.address }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Боковая панель -->
|
||||
<div class="content-sidebar">
|
||||
<!-- Блок бронирования -->
|
||||
<div class="booking-card card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Бронирование</h3>
|
||||
<div class="price-info">
|
||||
<span class="price-large">{{ formatPrice(object?.price) }}</span>
|
||||
<span class="price-period">за ночь</span>
|
||||
<div class="header-actions">
|
||||
<div class="price-section">
|
||||
<div class="price">{{ formatPrice(object.price) }}</div>
|
||||
<div class="price-period">{{ pricePeriodLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="booking-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Даты</label>
|
||||
<div class="date-inputs">
|
||||
<input v-model="bookingDates.checkIn" type="date" class="form-input" placeholder="Заезд">
|
||||
<input v-model="bookingDates.checkOut" type="date" class="form-input" placeholder="Выезд">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Гости</label>
|
||||
<select v-model="bookingGuests" class="form-select">
|
||||
<option value="1">1 гость</option>
|
||||
<option value="2">2 гостя</option>
|
||||
<option value="3">3 гостя</option>
|
||||
<option value="4">4 гостя</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-large"
|
||||
:disabled="!bookingDates.checkIn || !bookingDates.checkOut"
|
||||
@click="showBookingModal = true">
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary btn-large" @click="showBookingModal = true">
|
||||
Забронировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контактная информация -->
|
||||
<div class="contact-card card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Контактная информация</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="contact-actions">
|
||||
<a :href="`tel:${object?.contact.phone}`" class="btn btn-outline btn-with-icon">
|
||||
<span>📞</span>
|
||||
Позвонить
|
||||
</a>
|
||||
<a :href="`mailto:${object?.contact.email}`" class="btn btn-outline btn-with-icon">
|
||||
<span>✉️</span>
|
||||
Написать
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-section">
|
||||
<div class="main-image">
|
||||
<img :src="mainImage" :alt="object.title || object.short_name" class="gallery-image" @click="openGallery(0)">
|
||||
</div>
|
||||
|
||||
<!-- Действия владельца -->
|
||||
<div v-if="isOwner" class="owner-actions card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Управление объектом</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="action-buttons">
|
||||
<NuxtLink :to="`/objects/${object?.id}/edit`" class="btn btn-outline btn-with-icon">
|
||||
<span>✏️</span>
|
||||
Редактировать
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn btn-outline btn-with-icon"
|
||||
:class="{ 'btn-primary': !object?.isActive }"
|
||||
@click="toggleObjectStatus">
|
||||
<span>{{ object?.isActive ? '⏸️' : '▶️' }}</span>
|
||||
{{ object?.isActive ? 'Деактивировать' : 'Активировать' }}
|
||||
</button>
|
||||
<div v-if="object.images && object.images.length > 1" class="thumbnails">
|
||||
<div
|
||||
v-for="(image, index) in object.images.slice(1, 5)"
|
||||
:key="image.id"
|
||||
class="thumbnail"
|
||||
@click="openGallery(index + 1)">
|
||||
<img :src="image.url" :alt="`${object.title} - фото ${index + 2}`">
|
||||
<div v-if="index === 3 && object.images.length > 5" class="more-images">
|
||||
+{{ object.images.length - 5 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-main">
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Описание</h2>
|
||||
<p class="object-description">{{ object.description || object.short_description }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="object.amenities && object.amenities.length" class="content-section">
|
||||
<h2 class="section-title">Удобства</h2>
|
||||
<div class="amenities-grid">
|
||||
<div v-for="amenity in object.amenities" :key="amenity.id" class="amenity-item">
|
||||
<span class="amenity-icon">{{ amenity.icon || '✅' }}</span>
|
||||
<span class="amenity-text">{{ amenity.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Контакты</h2>
|
||||
<div class="contact-info">
|
||||
<div v-if="object.phone" class="contact-item">
|
||||
<span class="contact-icon">📞</span>
|
||||
<a :href="`tel:${object.phone}`" class="contact-link">{{ object.phone }}</a>
|
||||
</div>
|
||||
<div v-if="object.email" class="contact-item">
|
||||
<span class="contact-icon">✉️</span>
|
||||
<a :href="`mailto:${object.email}`" class="contact-link">{{ object.email }}</a>
|
||||
</div>
|
||||
<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-text">{{ object.address }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="content-sidebar">
|
||||
<div class="booking-card card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Бронирование</h3>
|
||||
<div class="price-info">
|
||||
<span class="price-large">{{ formatPrice(object.price) }}</span>
|
||||
<span class="price-period">{{ pricePeriodLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="booking-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Даты</label>
|
||||
<div class="date-inputs">
|
||||
<input v-model="bookingDates.checkIn" type="date" class="form-input" placeholder="Заезд">
|
||||
<input v-model="bookingDates.checkOut" type="date" class="form-input" placeholder="Выезд">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Гости</label>
|
||||
<select v-model="bookingGuests" class="form-select">
|
||||
<option value="1">1 гость</option>
|
||||
<option value="2">2 гостя</option>
|
||||
<option value="3">3 гостя</option>
|
||||
<option value="4">4 гостя</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-large"
|
||||
:disabled="!bookingDates.checkIn || !bookingDates.checkOut"
|
||||
@click="showBookingModal = true">
|
||||
Забронировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-card card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Контактная информация</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="contact-actions">
|
||||
<a v-if="object.phone" :href="`tel:${object.phone}`" class="btn btn-outline btn-with-icon">
|
||||
<span>📞</span>
|
||||
Позвонить
|
||||
</a>
|
||||
<a v-if="object.email" :href="`mailto:${object.email}`" class="btn btn-outline btn-with-icon">
|
||||
<span>✉️</span>
|
||||
Написать
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isOwner" class="owner-actions card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Управление объектом</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="action-buttons">
|
||||
<NuxtLink :to="`/objects/${object.id}/edit`" class="btn btn-outline btn-with-icon">
|
||||
<span>✏️</span>
|
||||
Редактировать
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn btn-outline btn-with-icon"
|
||||
:class="{ 'btn-primary': !object.is_active }"
|
||||
@click="toggleObjectStatus">
|
||||
<span>{{ object.is_active ? '⏸️' : '▶️' }}</span>
|
||||
{{ object.is_active ? 'Деактивировать' : 'Активировать' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline btn-with-icon delete-btn"
|
||||
@click="deleteObject">
|
||||
<span>🗑️</span>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 object = ref<ObjectData | null>(null)
|
||||
const reviews = ref<Review[]>([])
|
||||
const { getById, remove } = useObjects()
|
||||
const { user } = useAuth()
|
||||
|
||||
const object = ref<ObjectResponse | null>(null)
|
||||
const loading = ref(true)
|
||||
const showBookingModal = ref(false)
|
||||
const showGallery = ref(false)
|
||||
const galleryIndex = ref(0)
|
||||
const isOwner = ref(false)
|
||||
|
||||
// Данные бронирования
|
||||
const bookingDates = ref({
|
||||
checkIn: '',
|
||||
checkOut: ''
|
||||
})
|
||||
const bookingDates = ref({ checkIn: '', checkOut: '' })
|
||||
const bookingGuests = ref('2')
|
||||
|
||||
// Мок-данные объекта
|
||||
const mockObject: ObjectData = {
|
||||
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 touristScore = computed(() => {
|
||||
return object.value?.feedback_count || 0
|
||||
})
|
||||
|
||||
// Мок-отзывы
|
||||
const mockReviews: Review[] = [
|
||||
{
|
||||
id: 1,
|
||||
author: { name: 'Анна Петрова' },
|
||||
rating: 5,
|
||||
text: 'Прекрасное место для отдыха! Очень уютные номера, вкусные завтраки и великолепный вид на озеро. Обязательно вернемся снова.',
|
||||
date: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: { name: 'Иван Сидоров' },
|
||||
rating: 4,
|
||||
text: 'Отличный гостевой дом, все понравилось. Особенно порадовала баня и рыбалка. Персонал очень внимательный и доброжелательный.',
|
||||
date: '2024-01-10'
|
||||
const mainImage = computed(() => {
|
||||
if (object.value?.images?.length) {
|
||||
return object.value.images[0].url
|
||||
}
|
||||
]
|
||||
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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Имитация загрузки данных
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
object.value = mockObject
|
||||
reviews.value = mockReviews
|
||||
const id = parseInt(route.params.id as string)
|
||||
object.value = await getById(id)
|
||||
|
||||
// Проверка владельца (в реальном приложении - по ID пользователя)
|
||||
isOwner.value = object.value.ownerId === 1
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: `${object.value.title} - EasySite`,
|
||||
description: object.value.description,
|
||||
ogTitle: object.value.title,
|
||||
ogDescription: object.value.description,
|
||||
ogImage: object.value.images[0]
|
||||
title: `${object.value.title || object.value.short_name} - EasySite`,
|
||||
description: object.value.description || object.value.short_description,
|
||||
ogTitle: object.value.title || object.value.short_name,
|
||||
ogDescription: object.value.description || object.value.short_description,
|
||||
ogImage: mainImage.value
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading object:', error)
|
||||
showError({ statusCode: 404, statusMessage: 'Объект не найден' })
|
||||
object.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Методы
|
||||
const getTypeLabel = (type: string | undefined) => {
|
||||
const types: Record<string, string> = {
|
||||
hotel: '🏨 Гостиница',
|
||||
@@ -402,11 +281,11 @@ const getTypeLabel = (type: string | undefined) => {
|
||||
tour: '🧳 Тур',
|
||||
restaurant: '🍴 Ресторан'
|
||||
}
|
||||
return types[type || ''] || type
|
||||
return types[type || ''] || type || ''
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | undefined) => {
|
||||
if (!price) return '0 ₽'
|
||||
if (!price && price !== 0) return '—'
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
@@ -414,14 +293,6 @@ const formatPrice = (price: number | undefined) => {
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const openGallery = (index: number) => {
|
||||
galleryIndex.value = index
|
||||
showGallery.value = true
|
||||
@@ -429,15 +300,24 @@ const openGallery = (index: number) => {
|
||||
|
||||
const toggleObjectStatus = async () => {
|
||||
if (!object.value) return
|
||||
|
||||
object.value.isActive = !object.value.isActive
|
||||
// В реальном приложении здесь был бы API-запрос
|
||||
// TODO: Implement status toggle via update API
|
||||
object.value.is_active = !object.value.is_active
|
||||
}
|
||||
|
||||
const handleBooking = (bookingData: unknown) => {
|
||||
console.log('Booking confirmed:', bookingData)
|
||||
const deleteObject = async () => {
|
||||
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
|
||||
// Здесь обработка бронирования
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -535,10 +415,6 @@ const handleBooking = (bookingData: unknown) => {
|
||||
color: var(--gray-300);
|
||||
}
|
||||
|
||||
.rating-stars.small .rating-star {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rating-value {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
@@ -612,7 +488,6 @@ const handleBooking = (bookingData: unknown) => {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Галерея */
|
||||
.gallery-section {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
@@ -680,7 +555,6 @@ const handleBooking = (bookingData: unknown) => {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* Основной контент */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
@@ -710,32 +584,6 @@ const handleBooking = (bookingData: unknown) => {
|
||||
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 {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
@@ -799,74 +647,6 @@ const handleBooking = (bookingData: unknown) => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -908,13 +688,46 @@ const handleBooking = (bookingData: unknown) => {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.owner-actions .action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
.delete-btn {
|
||||
color: var(--danger-500);
|
||||
border-color: var(--danger-200);
|
||||
}
|
||||
|
||||
.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) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -958,16 +771,6 @@ const handleBooking = (bookingData: unknown) => {
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -984,25 +787,16 @@ const handleBooking = (bookingData: unknown) => {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gallery-section {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.thumbnails {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div class="page-wrapper">
|
||||
<main class="create-object-page">
|
||||
<div class="container max-w-4xl">
|
||||
<!-- Заголовок -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
@@ -15,37 +14,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<ObjectForm :loading="loading" @submit="handleSubmit" @cancel="handleCancel" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const { create } = useObjects()
|
||||
const loading = ref(false)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSubmit = async (formData: any) => {
|
||||
const handleSubmit = async (formData: Record<string, unknown>) => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// Имитация создания объекта
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
console.log('Создание объекта:', {
|
||||
...formData,
|
||||
userId: 1,
|
||||
isActive: true,
|
||||
images: formData.images || ['/images/placeholder.jpg'],
|
||||
amenities: formData.amenities || []
|
||||
})
|
||||
|
||||
// Показываем уведомление об успехе
|
||||
alert('Объект успешно создан!')
|
||||
await navigateTo('/objects/my-objects')
|
||||
await create(formData as Parameters<typeof create>[0])
|
||||
navigateTo('/objects/my-objects')
|
||||
} catch (error) {
|
||||
console.error('Error creating object:', error)
|
||||
alert('Ошибка при создании объекта')
|
||||
@@ -79,11 +63,13 @@ const handleCancel = () => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -91,34 +77,18 @@ const handleCancel = () => {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
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">
|
||||
<div class="container">
|
||||
<!-- Заголовок и действия -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Все объекты</h1>
|
||||
<p class="page-subtitle">Найдено {{ filteredObjects.length }} объектов</p>
|
||||
<p class="page-subtitle">Найдено {{ totalObjects }} объектов</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-outline btn-with-icon" @click="showFilters = !showFilters">
|
||||
@@ -25,20 +24,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые фильтры -->
|
||||
<div class="quick-filters">
|
||||
<button
|
||||
v-for="type in quickTypes"
|
||||
:key="type.value"
|
||||
v-for="type in quickTypes"
|
||||
:key="type.value"
|
||||
class="quick-filter"
|
||||
:class="{ active: filters.type === type.value }"
|
||||
:class="{ active: filters.type === type.value }"
|
||||
@click="toggleQuickFilter(type.value)">
|
||||
{{ type.icon }} {{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Расширенные фильтры -->
|
||||
<div v-if="showFilters" class="search-filters card">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
@@ -52,46 +49,27 @@
|
||||
<option value="guest_house">🏡 Гостевой дом</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Город</label>
|
||||
<input v-model="filters.city" type="text" class="form-input" placeholder="Введите город">
|
||||
<label class="form-label">Поиск</label>
|
||||
<input v-model="filters.q" type="text" class="form-input" placeholder="Название или адрес">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Цена до</label>
|
||||
<input v-model="filters.maxPrice" type="number" class="form-input" placeholder="Макс. цена">
|
||||
</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 class="filter-actions">
|
||||
<button class="btn btn-primary" @click="applyFilters">
|
||||
Применить фильтры
|
||||
</button>
|
||||
<button class="btn btn-outline" @click="resetFilters">
|
||||
Сбросить
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="applyFilters">Применить фильтры</button>
|
||||
<button class="btn btn-outline" @click="resetFilters">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Управление видом -->
|
||||
<div class="view-controls">
|
||||
<div class="sort-controls">
|
||||
<select v-model="sortBy" class="form-select">
|
||||
<option value="title">По названию</option>
|
||||
<option value="price">По цене</option>
|
||||
<option value="rating">По рейтингу</option>
|
||||
<option value="city">По городу</option>
|
||||
</select>
|
||||
<button class="btn btn-outline btn-sm" @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
|
||||
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
||||
@@ -112,39 +90,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Результаты -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner"/>
|
||||
<p class="loading-text">Загрузка объектов...</p>
|
||||
</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>
|
||||
<h3 class="empty-title">Объекты не найдены</h3>
|
||||
<p class="empty-description">Попробуйте изменить параметры поиска</p>
|
||||
<button class="btn btn-primary" @click="resetFilters">
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="resetFilters">Сбросить фильтры</button>
|
||||
</div>
|
||||
|
||||
<!-- Сетка объектов -->
|
||||
<div v-else class="objects-grid" :class="viewMode === 'grid' ? 'grid-view' : 'list-view'">
|
||||
<ObjectCard
|
||||
v-for="object in paginatedObjects"
|
||||
:key="object.id"
|
||||
:object="object"
|
||||
<ObjectCard
|
||||
v-for="object in paginatedObjects"
|
||||
:key="object.id"
|
||||
:object="object"
|
||||
:view-mode="viewMode"
|
||||
@click="navigateToObject(object.id)" />
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
<div v-if="!loading && filteredObjects.length > 0" class="pagination">
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
<div v-if="!loading && objects.length > 0" class="pagination">
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="pagination-btn"
|
||||
:class="{ active: currentPage === page }"
|
||||
@click="currentPage = page" >
|
||||
@click="currentPage = page">
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -155,44 +128,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ObjectShortResponse } from '~/types/objects'
|
||||
|
||||
interface ObjectItem {
|
||||
id: number
|
||||
title: string
|
||||
type: string
|
||||
city: string
|
||||
price: number
|
||||
rating: number
|
||||
image: string
|
||||
description: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
useHead({ title: 'Все объекты - EasySite' })
|
||||
|
||||
// Навигация
|
||||
useHead({
|
||||
title: 'Все объекты - EasySite'
|
||||
})
|
||||
const { getList } = useObjects()
|
||||
|
||||
// Состояние
|
||||
const objects = ref<ObjectItem[]>([])
|
||||
const objects = ref<ObjectShortResponse[]>([])
|
||||
const totalObjects = ref(0)
|
||||
const loading = ref(true)
|
||||
const showFilters = ref(false)
|
||||
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 currentPage = ref(1)
|
||||
const itemsPerPage = 9
|
||||
|
||||
const filters = ref({
|
||||
search: '',
|
||||
q: '',
|
||||
type: '',
|
||||
city: '',
|
||||
maxPrice: null as number | null,
|
||||
minRating: 0
|
||||
maxPrice: null as number | null
|
||||
})
|
||||
|
||||
// Быстрые фильтры
|
||||
const quickTypes = [
|
||||
{ value: 'hotel', label: 'Гостиницы', icon: '🏨' },
|
||||
{ value: 'sanatorium', label: 'Санатории', icon: '🏥' },
|
||||
@@ -200,107 +157,28 @@ const quickTypes = [
|
||||
{ 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(() => {
|
||||
return Object.values(filters.value).filter(val =>
|
||||
val !== '' && val !== null && val !== 0
|
||||
val !== '' && val !== null
|
||||
).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 sorted = [...filteredObjects.value].sort((a, b) => {
|
||||
let aVal = a[sortBy.value]
|
||||
let bVal = b[sortBy.value]
|
||||
|
||||
if (sortBy.value === 'price' || sortBy.value === 'rating') {
|
||||
const aNum = Number(aVal)
|
||||
const bNum = Number(bVal)
|
||||
return sortOrder.value === 'asc' ? aNum - bNum : bNum - aNum
|
||||
const sorted = [...objects.value].sort((a, b) => {
|
||||
if (sortBy.value === 'price') {
|
||||
const aVal = a.price || 0
|
||||
const bVal = b.price || 0
|
||||
return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
|
||||
}
|
||||
|
||||
aVal = String(aVal).toLowerCase()
|
||||
bVal = String(bVal).toLowerCase()
|
||||
|
||||
if (sortOrder.value === 'asc') {
|
||||
return aVal.localeCompare(bVal)
|
||||
} else {
|
||||
return bVal.localeCompare(aVal)
|
||||
if (sortBy.value === 'rating') {
|
||||
const aVal = a.tourist_average_score || a.entrepreneur_average_score || 0
|
||||
const bVal = b.tourist_average_score || b.entrepreneur_average_score || 0
|
||||
return sortOrder.value === 'asc' ? aVal - bVal : bVal - 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
|
||||
})
|
||||
|
||||
@@ -310,10 +188,9 @@ const paginatedObjects = computed(() => {
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredObjects.value.length / itemsPerPage)
|
||||
return Math.ceil(sortedObjects.value.length / itemsPerPage)
|
||||
})
|
||||
|
||||
// Методы
|
||||
const toggleQuickFilter = (type: string) => {
|
||||
filters.value.type = filters.value.type === type ? '' : type
|
||||
applyFilters()
|
||||
@@ -321,35 +198,44 @@ const toggleQuickFilter = (type: string) => {
|
||||
|
||||
const applyFilters = () => {
|
||||
currentPage.value = 1
|
||||
loadObjects()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
search: '',
|
||||
type: '',
|
||||
city: '',
|
||||
maxPrice: null,
|
||||
minRating: 0
|
||||
}
|
||||
filters.value = { q: '', type: '', maxPrice: null }
|
||||
currentPage.value = 1
|
||||
loadObjects()
|
||||
}
|
||||
|
||||
const navigateToObject = (id: number) => {
|
||||
navigateTo(`/objects/${id}`)
|
||||
}
|
||||
|
||||
// Инициализация
|
||||
onMounted(async () => {
|
||||
const loadObjects = async () => {
|
||||
loading.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
objects.value = mockObjects
|
||||
loading.value = false
|
||||
})
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Следим за изменениями фильтров
|
||||
watch([filters, sortBy, sortOrder], () => {
|
||||
applyFilters()
|
||||
})
|
||||
onMounted(loadObjects)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -372,12 +258,14 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -385,12 +273,13 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -401,37 +290,36 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
|
||||
.quick-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-filter {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quick-filter:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.quick-filter:hover,
|
||||
.quick-filter.active {
|
||||
background: var(--primary-500);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.filter-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);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
@@ -439,7 +327,6 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
@@ -447,16 +334,12 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
@@ -464,21 +347,27 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.objects-grid.grid-view {
|
||||
.objects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
.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 {
|
||||
@@ -496,18 +385,10 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
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 {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--space-lg);
|
||||
opacity: 0.5;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@@ -527,97 +408,44 @@ watch([filters, sortBy, sortOrder], () => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.pagination-btn.active,
|
||||
.pagination-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--primary-500);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.cursor-pointer:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.header-actions .btn {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
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 {
|
||||
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">
|
||||
<div class="container">
|
||||
<!-- Заголовок -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
@@ -23,7 +22,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<div class="search-filters card">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
@@ -31,7 +29,10 @@
|
||||
<select v-model="filters.status" class="form-select">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="draft">Черновики</option>
|
||||
<option value="moderation">На модерации</option>
|
||||
<option value="inactive">Неактивные</option>
|
||||
<option value="rejected">Отклонённые</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -46,96 +47,77 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="filter-actions">
|
||||
<button class="btn btn-primary" @click="applyFilters">
|
||||
Применить
|
||||
</button>
|
||||
<button class="btn btn-outline" @click="resetFilters">
|
||||
Сбросить
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="applyFilters">Применить</button>
|
||||
<button class="btn btn-outline" @click="resetFilters">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сетка карточек -->
|
||||
<div class="objects-grid">
|
||||
<!-- Карточка добавления нового объекта -->
|
||||
<div class="add-card" @click="navigateToCreate">
|
||||
<div class="add-card-content">
|
||||
<div class="add-icon">➕</div>
|
||||
<h3 class="add-title">Добавить объект</h3>
|
||||
<p class="add-description">Создайте новое место для размещения</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner"/>
|
||||
<p class="loading-text">Загрузка объектов...</p>
|
||||
</div>
|
||||
|
||||
<!-- Карточки объектов -->
|
||||
<div v-for="object in myObjects" :key="object.id" class="object-card">
|
||||
<div class="card-image">
|
||||
<img :src="object.image || '/images/placeholder.jpg'" :alt="object.title" >
|
||||
<div class="card-badge" :class="object.isActive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ object.isActive ? 'Активен' : 'Неактивен' }}
|
||||
<template v-else>
|
||||
<div class="objects-grid">
|
||||
<div class="add-card" @click="navigateTo('/objects/create')">
|
||||
<div class="add-card-content">
|
||||
<div class="add-icon">➕</div>
|
||||
<h3 class="add-title">Добавить объект</h3>
|
||||
<p class="add-description">Создайте новое место для размещения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{{ object.title }}</h3>
|
||||
<div class="card-meta">
|
||||
<span class="card-type">{{ getTypeLabel(object.type) }}</span>
|
||||
<span class="card-location">📍 {{ object.city }}</span>
|
||||
<div v-for="item in filteredObjects" :key="item.id" class="object-card">
|
||||
<div class="card-image">
|
||||
<img :src="'/images/placeholder.jpg'" :alt="item.title || item.short_name">
|
||||
<div class="card-badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel(item.status) }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-description">{{ truncateDescription(object.description) }}</p>
|
||||
<div class="card-price">{{ formatPrice(object.price) }}</div>
|
||||
<div class="card-date">Добавлен: {{ formatDate(object.createdAt) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<!-- Кнопка просмотра объекта -->
|
||||
<NuxtLink
|
||||
:to="`/objects/${object.id}`"
|
||||
class="btn btn-outline btn-sm btn-with-icon view-btn"
|
||||
title="Просмотреть объект">
|
||||
<span>👁️</span>
|
||||
Просмотр
|
||||
</NuxtLink>
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{{ item.title || item.short_name }}</h3>
|
||||
<div class="card-meta">
|
||||
<span class="card-type">{{ getTypeLabel(item.type) }}</span>
|
||||
</div>
|
||||
<p class="card-description">{{ item.address || 'Адрес не указан' }}</p>
|
||||
<div class="card-price">{{ formatPrice(item.price) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<NuxtLink
|
||||
:to="`/objects/${object.id}/edit`"
|
||||
class="btn btn-outline btn-sm btn-with-icon"
|
||||
title="Редактировать">
|
||||
<span>✏️</span>
|
||||
<div class="card-actions">
|
||||
<NuxtLink :to="`/objects/${item.id}`" class="btn btn-outline btn-sm btn-with-icon view-btn">
|
||||
<span>👁️</span>
|
||||
Просмотр
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn btn-outline btn-sm btn-with-icon delete-btn"
|
||||
title="Удалить"
|
||||
@click="deleteObject(object.id)">
|
||||
<span>🗑️</span>
|
||||
</button>
|
||||
<div class="action-buttons">
|
||||
<NuxtLink :to="`/objects/${item.id}/edit`" class="btn btn-outline btn-sm btn-with-icon">
|
||||
<span>✏️</span>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn btn-outline btn-sm btn-with-icon delete-btn"
|
||||
@click="deleteObject(item.id)">
|
||||
<span>🗑️</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Пустой state -->
|
||||
<div v-if="!loading && myObjects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">🏢</div>
|
||||
<h3 class="empty-title">У вас пока нет объектов</h3>
|
||||
<p class="empty-description">Добавьте первый объект, чтобы начать работу</p>
|
||||
<NuxtLink to="/objects/create" class="btn btn-primary">
|
||||
Добавить первый объект
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div v-if="!loading && filteredObjects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">🏢</div>
|
||||
<h3 class="empty-title">У вас пока нет объектов</h3>
|
||||
<p class="empty-description">Добавьте первый объект, чтобы начать работу</p>
|
||||
<NuxtLink to="/objects/create" class="btn btn-primary">Добавить первый объект</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Навигация -->
|
||||
<div class="page-navigation">
|
||||
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon">
|
||||
← Все объекты
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/" class="btn btn-outline btn-with-icon">
|
||||
🏠 На главную
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon">← Все объекты</NuxtLink>
|
||||
<NuxtLink to="/" class="btn btn-outline btn-with-icon">🏠 На главную</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -144,130 +126,58 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface MyObjectItem {
|
||||
id: number
|
||||
title: string
|
||||
type: string
|
||||
city: string
|
||||
price: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
image?: string
|
||||
description: string
|
||||
}
|
||||
import type { ObjectShortResponse } from '~/types/objects'
|
||||
|
||||
// Мок-данные с изображениями и описаниями
|
||||
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-дневный автобусный тур по древним городам России с проживанием в комфортабельных отелях.'
|
||||
const { getMy, remove } = useObjects()
|
||||
|
||||
const objects = ref<ObjectShortResponse[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
const filters = ref({ status: '', type: '', q: '' })
|
||||
|
||||
const filteredObjects = computed(() => {
|
||||
let result = [...objects.value]
|
||||
if (filters.value.status) {
|
||||
result = result.filter(o => o.status === filters.value.status)
|
||||
}
|
||||
]
|
||||
|
||||
const myObjects = ref<MyObjectItem[]>(mockMyObjects)
|
||||
const loading = ref(false)
|
||||
|
||||
const filters = ref({
|
||||
status: '',
|
||||
type: '',
|
||||
search: ''
|
||||
if (filters.value.type) {
|
||||
result = result.filter(o => o.type === filters.value.type)
|
||||
}
|
||||
if (filters.value.q) {
|
||||
const q = filters.value.q.toLowerCase()
|
||||
result = result.filter(o =>
|
||||
(o.title || o.short_name || '').toLowerCase().includes(q) ||
|
||||
(o.address || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Навигация
|
||||
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 applyFilters = () => {}
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: '',
|
||||
type: '',
|
||||
search: ''
|
||||
}
|
||||
myObjects.value = mockMyObjects
|
||||
filters.value = { status: '', type: '', q: '' }
|
||||
}
|
||||
|
||||
const deleteObject = async (id: number) => {
|
||||
if (confirm('Вы уверены, что хотите удалить этот объект?')) {
|
||||
myObjects.value = myObjects.value.filter(obj => obj.id !== id)
|
||||
const statusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
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) => {
|
||||
@@ -281,12 +191,8 @@ const getTypeLabel = (type: string) => {
|
||||
return types[type] || type
|
||||
}
|
||||
|
||||
const truncateDescription = (description: string, maxLength: number = 100) => {
|
||||
if (description.length <= maxLength) return description
|
||||
return description.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
const formatPrice = (price: number | undefined) => {
|
||||
if (!price && price !== 0) return '—'
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
@@ -294,9 +200,30 @@ const formatPrice = (price: number) => {
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU')
|
||||
const deleteObject = async (id: number) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -319,11 +246,13 @@ const formatDate = (dateString: string) => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -331,12 +260,13 @@ const formatDate = (dateString: string) => {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -345,6 +275,11 @@ const formatDate = (dateString: string) => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
@@ -355,92 +290,70 @@ const formatDate = (dateString: string) => {
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Сетка карточек */
|
||||
.objects-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);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* Карточка добавления */
|
||||
.add-card {
|
||||
background: var(--bg-primary);
|
||||
border: 2px dashed var(--border-medium);
|
||||
border: 2px dashed var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 300px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.add-card:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.add-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 3rem;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.add-card:hover .add-icon {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.add-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-xl);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.add-description {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Карточка объекта */
|
||||
.object-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.object-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -448,11 +361,6 @@ const formatDate = (dateString: string) => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.object-card:hover .card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
@@ -463,13 +371,11 @@ const formatDate = (dateString: string) => {
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-50);
|
||||
color: var(--success-600);
|
||||
background: var(--success-100);
|
||||
color: var(--success-700);
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@@ -477,86 +383,62 @@ const formatDate = (dateString: string) => {
|
||||
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 {
|
||||
padding: var(--space-lg);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-xl);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--primary-600);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.card-location {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 2px var(--space-xs);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: var(--space-xs) 0;
|
||||
flex: 1;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-md);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-2xl);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--primary-600);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
padding: var(--space-lg);
|
||||
padding-top: 0;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-top: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
@@ -564,63 +446,62 @@ const formatDate = (dateString: string) => {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: var(--error-50);
|
||||
border-color: var(--error-300);
|
||||
color: var(--error-600);
|
||||
.delete-btn {
|
||||
color: var(--danger-500);
|
||||
border-color: var(--danger-200);
|
||||
}
|
||||
|
||||
/* Пустой state */
|
||||
.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);
|
||||
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 {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--space-lg);
|
||||
opacity: 0.5;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.page-navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.header-actions .btn {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
@@ -629,95 +510,11 @@ const formatDate = (dateString: string) => {
|
||||
|
||||
.objects-grid {
|
||||
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 {
|
||||
flex-direction: column;
|
||||
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,37 +1,27 @@
|
||||
// types/auth.ts
|
||||
export interface LoginForm {
|
||||
email: string
|
||||
password: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterForm {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirm: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirm: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
full_name: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
inn?: string | null
|
||||
phone?: string | null
|
||||
city?: string | null
|
||||
org_type?: string | null
|
||||
org_full_name?: string | null
|
||||
org_short_name?: string | null
|
||||
org_inn?: string | null
|
||||
email_verified_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
full_name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
// Статистика (если есть в API)
|
||||
objects_count?: number
|
||||
reviews_count?: number
|
||||
active_objects_count?: number
|
||||
moderation_objects_count?: number
|
||||
}
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
expires_at: string
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
|
||||
// Настройки для работы за прокси
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api/v1',
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
telegramBotToken: process.env.NUXT_PUBLIC_TELEGRAM_BOT_TOKEN,
|
||||
telegramChatId: process.env.NUXT_PUBLIC_TELEGRAM_CHAT_ID,
|
||||
|
||||
Reference in New Issue
Block a user