Prechádzať zdrojové kódy

fix: синхронизация фронтенд-типов с бэкендом, исправление API_CONTRACT.md

- Place.images: string[] → PlaceImage[] (массив объектов)
- Удалён phantom-поле executor_services и интерфейс ExecutorService
- Service.portfolio_images: string[] → ServiceImage[]
- Review: добавлены поля user_id, target_type, target_id, updated_at
- Booking: добавлено поле user_id
- API_CONTRACT.md: refresh token — cookie вместо X-Refresh-Token header
- Obsidian: atomic-заметки о расхождениях и фиксах
neyrogovnarik 1 mesiac pred
rodič
commit
6604c79593

+ 3 - 3
backend/docs/API_CONTRACT.md

@@ -7,7 +7,7 @@
 
 ## Аутентификация
 - **Access Token**: JWT, срок 15 мин, в заголовке `Authorization: Bearer <token>`
-- **Refresh Token**: JWT, срок 30 дней, передаётся в заголовке `X-Refresh-Token`
+- **Refresh Token**: JWT, срок 30 дней, передаётся в httpOnly cookie `refresh_token`
 - **Роли**: `superadmin`, `moderator`, `landlord`, `executor`, `customer`
 
 ## Формат ошибок
@@ -58,8 +58,8 @@ HTTP статусы: 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404
 ```
 
 ### POST /auth/refresh
-- Заголовок: `X-Refresh-Token: <refresh_token>`
-- Ответ 200: новый access_token + обновлённый refresh_token
+- Cookie: `refresh_token` (httpOnly, устанавливается при login/register/refresh)
+- Ответ 200: новый access_token + новый refresh_token в cookie
 
 ### POST /auth/logout
 - Заголовок: `Authorization: Bearer <access_token>`

+ 38 - 20
frontend/src/types/index.ts

@@ -36,6 +36,24 @@ export type PlaceType = 'place' | 'studio'
 /** Статус места: черновик, на модерации, опубликовано, отклонено, на доработке или в архиве */
 export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'rejected' | 'revision' | 'archived'
 
+/**
+ * Изображение места
+ * @property id - Уникальный идентификатор
+ * @property place_id - Идентификатор места
+ * @property url - URL изображения
+ * @property alt - Альтернативный текст (опционально)
+ * @property sort_order - Порядок сортировки
+ * @property is_cover - Является ли обложкой
+ */
+export interface PlaceImage {
+  id: string
+  place_id: string
+  url: string
+  alt?: string
+  sort_order: number
+  is_cover: boolean
+}
+
 /**
  * Место или студия для фотосъёмок
  * @property id - Уникальный идентификатор
@@ -46,7 +64,7 @@ export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'reject
  * @property lat - Широта
  * @property lng - Долгота
  * @property cover_image - URL обложки (опционально)
- * @property images - Массив URL изображений
+ * @property images - Массив изображений
  * @property access_info - Информация о доступе (опционально)
  * @property rating - Рейтинг
  * @property reviews_count - Количество отзывов
@@ -54,11 +72,9 @@ export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'reject
  * @property features - Список характеристик
  * @property status - Статус публикации
  * @property owner - Владелец (id, name, avatar_url)
- * @property pricing - Ценообразование для студий (опционально)
  * @property booking_url - Ссылка на бронирование (опционально)
- * @property executor_services - Услуги исполнителей (опционально)
  * @property created_at - Дата создания
- * @property updated_at - Дата обновления (опционально)
+ * @property updated_at - Дата обновления
  */
 export interface Place {
   id: string
@@ -69,7 +85,7 @@ export interface Place {
   lat: number
   lng: number
   cover_image?: string
-  images: string[]
+  images: PlaceImage[]
   access_info?: string
   rating: number
   reviews_count: number
@@ -82,27 +98,24 @@ export interface Place {
   currency?: string
   min_hours?: number
   booking_url?: string
-  executor_services?: ExecutorService[]
   created_at: string
-  updated_at?: string
+  updated_at: string
 }
 
 /**
- * Услуга, предоставляемая исполнителем на месте съёмки
+ * Изображение из портфолио услуги
  * @property id - Уникальный идентификатор
- * @property title - Название услуги
- * @property price - Стоимость
- * @property currency - Валюта
- * @property duration_minutes - Длительность в минутах (опционально)
- * @property executor_name - Имя исполнителя
+ * @property service_id - Идентификатор услуги
+ * @property url - URL изображения
+ * @property alt - Альтернативный текст (опционально)
+ * @property sort_order - Порядок сортировки
  */
-export interface ExecutorService {
+export interface ServiceImage {
   id: string
-  title: string
-  price: number
-  currency: string
-  duration_minutes?: number
-  executor_name: string
+  service_id: string
+  url: string
+  alt?: string
+  sort_order: number
 }
 
 /**
@@ -158,7 +171,7 @@ export interface Service {
   tags: Tag[]
   rating: number
   reviews_count: number
-  portfolio_images: string[]
+  portfolio_images: ServiceImage[]
   status: 'draft' | 'published' | 'archived'
   created_at: string
 }
@@ -173,10 +186,14 @@ export interface Service {
  */
 export interface Review {
   id: string
+  user_id: string
+  target_type: 'place' | 'service'
+  target_id: string
   user: Pick<User, 'id' | 'name' | 'avatar_url'>
   rating: number
   text?: string
   created_at: string
+  updated_at: string
 }
 
 /**
@@ -193,6 +210,7 @@ export interface Review {
 export interface Booking {
   id: string
   place_id: string
+  user_id: string
   start_time: string
   end_time: string
   status: 'pending' | 'confirmed' | 'cancelled' | 'completed'

+ 2 - 0
obsidian_data/Photoplaces_data/MOC-backend-patterns.md

@@ -33,6 +33,7 @@ graph TB
 | Package-level mutable state (anti-pattern) | `handlers/errors.go:13` | [[atomic-global-appenv-package-var]] |
 | TOCTOU race (anti-pattern) | `services/auth.go:85-86` | [[atomic-toctou-race-registration]] |
 | PATCH tags/features fix | `handlers/places.go`, `services/places.go` | [[decision-patch-tags-features-fix]] |
+| Refresh token — cookie вместо header | `handlers/helpers.go`, `API_CONTRACT.md` | [[atomic-api-contract-refresh-cookie]] |
 
 ### Error handling chain
 
@@ -82,6 +83,7 @@ sequenceDiagram
 | N+1 запросы | `services/places.go:112-136` | [[atomic-n-plus-one-getbyid]] |
 | Silenced errors | `PlaceForm.tsx:42-43` | [[atomic-error-swallowing-frontend]] |
 | **PATCH не обновляет теги/фичи** | `handlers/places.go:263-308`, `services/places.go:194-242` | [[atomic-patch-place-tags-features]] |
+| **Фронтенд-типы не синхронизированы** | `frontend/src/types/index.ts` | [[atomic-frontend-types-backend-sync]] |
 
 ### Связанные заметки
 

+ 31 - 0
obsidian_data/Photoplaces_data/atomic-api-contract-refresh-cookie.md

@@ -0,0 +1,31 @@
+# API контракт не соответствует реализации refresh token
+
+**Контекст**: `backend/docs/API_CONTRACT.md` описывает передачу refresh token через заголовок `X-Refresh-Token`, но реальная реализация использует httpOnly cookie.
+
+## Факт
+
+- **Документация**: `- Refresh Token: ... передаётся в заголовке X-Refresh-Token` + `### POST /auth/refresh` → `- Заголовок: X-Refresh-Token: <refresh_token>`
+- **Код**: `backend/internal/handlers/helpers.go:14-24` — `setRefreshTokenCookie()` устанавливает httpOnly cookie с именем `refresh_token`
+- **Фронтенд**: `frontend/src/lib/api.ts:29` — `credentials: 'include'` для автоматической отправки cookie
+
+## Исправление
+
+API_CONTRACT.md обновлён:
+- "передаётся в заголовке X-Refresh-Token" → "передаётся в httpOnly cookie `refresh_token`"
+- Описание POST /auth/refresh: "Cookie: refresh_token (httpOnly, устанавливается при login/register/refresh)"
+
+## Почему cookie, а не header
+
+1. **Безопасность**: httpOnly cookie недоступен JavaScript'у — XSS-атака не украдёт refresh token
+2. **Простота**: фронтенду не нужно хранить и явно отправлять refresh token — браузер делает это автоматически
+3. **Refresh token rotation**: при каждом refresh старый токен удаляется из БД (с проверкой reuse)
+4. **Cookie можно очистить через logout** на серверной стороне (MaxAge=-1)
+
+## Связанные заметки
+
+- [[atomic-frontend-types-backend-sync]] — другие разрывы контракта
+- [[MOC-backend-patterns]]
+
+## Теги
+
+#api-docs #security #cookie #httpOnly #refresh-token

+ 37 - 0
obsidian_data/Photoplaces_data/atomic-frontend-types-backend-sync.md

@@ -0,0 +1,37 @@
+# Фронтенд-типы не соответствуют бэкенд-моделям
+
+**Контекст**: При code review обнаружено 5 несоответствий между `frontend/src/types/index.ts` и Go-моделями бэкенда.
+
+## Найденные расхождения
+
+| Поле | Фронтенд (было) | Бэкенд (реальность) |
+|------|----------------|-------------------|
+| `Place.images` | `string[]` (массив URL) | `[]PlaceImage` (объекты с `url`, `alt`, `is_cover`...) |
+| `Place.executor_services` | `ExecutorService[]` | **Нет такого поля** — phantom |
+| `Service.portfolio_images` | `string[]` (массив URL) | `[]ServiceImage` (объекты) |
+| `Review` | Нет `user_id`, `target_type`, `target_id`, `updated_at` | Все поля есть |
+| `Booking` | Нет `user_id` | Есть |
+
+## Исправления
+
+1. **Place.images**: `string[]` → `PlaceImage[]`, добавлен интерфейс `PlaceImage` (поля из backend)
+2. **Place.executor_services**: удалён вместе с интерфейсом `ExecutorService` — поле-фантом, не используется ни в одном компоненте
+3. **Service.portfolio_images**: `string[]` → `ServiceImage[]`, добавлен интерфейс `ServiceImage`
+4. **Review**: добавлены `user_id`, `target_type`, `target_id`, `updated_at`
+5. **Booking**: добавлено `user_id`
+
+## Почему это важно
+
+Фронтенд-типы — это контракт с бэкендом. Расхождения ведут к:
+- Ошибкам времени компиляции при обращении к несуществующим полям
+- Потере данных при рендеринге (поле есть в ответе, но фронтенд его игнорирует)
+- Путанице при разработке (разработчик думает, что поле есть, а его нет)
+
+## Связанные заметки
+
+- [[atomic-api-contract-refresh-cookie]] — ещё один разрыв контракта
+- [[MOC-backend-patterns]] — таблица антипаттернов
+
+## Теги
+
+#frontend #typescript #api-contract #bug #type-safety