Admin BFF — BR 4.1

Контракты

18 admin endpoint’ов в Catalog Service — см. API Public endpoints /r/{slug}* обрабатываются Catalog Service напрямую (через nginx route), BFF не задействован

Что делаем

Прокси-роуты

  • bff/src/routes/admin/external-menus.ts — новый файл, prefix /api/v1/admin/external-menus. Используем существующий requireAuth middleware и forward Authorization header.
// CRUD меню
router.get('/', requireAuth, proxy('GET', '/external-menus'));
router.get('/archived', requireAuth, proxy('GET', '/external-menus/archived'));
router.get('/:id', requireAuth, proxy('GET', '/external-menus/:id'));
router.post('/', requireAuth, proxy('POST', '/external-menus'));
router.patch('/:id', requireAuth, proxy('PATCH', '/external-menus/:id'));
router.delete('/:id', requireAuth, proxy('DELETE', '/external-menus/:id'));
 
// Lifecycle
router.post('/:id/publish', requireAuth, proxy('POST', '/external-menus/:id/publish'));
router.post('/:id/unpublish', requireAuth, proxy('POST', '/external-menus/:id/unpublish'));
router.post('/:id/restore', requireAuth, proxy('POST', '/external-menus/:id/restore'));
router.post('/:id/duplicate', requireAuth, proxy('POST', '/external-menus/:id/duplicate'));
 
// Категории
router.post('/:id/categories', ...);
router.patch('/:id/categories/:catId', ...);
router.delete('/:id/categories/:catId', ...);
router.post('/:id/categories/reorder', ...);
 
// Items
router.post('/:id/items', ...);
router.patch('/:id/items/:itemId', ...);
router.delete('/:id/items/:itemId', ...);
router.post('/:id/items/reorder', ...);
router.post('/:id/items/:itemId/restore', ...);
 
// Export
router.get('/:id/export.json', requireAuth, proxy('GET', '/external-menus/:id/export.json'));
router.get('/:id/export.zip', requireAuth, async (req, res) => {
  // Special: stream binary response (не JSON)
  // Forward Authorization, set Content-Type/Disposition
});
  • Использовать существующий middleware createCatalogProxy (если есть) или унифицированную утилиту прокси. Не дублировать auth-логику.
  • Особый случай — /export.zip — не пытаться парсить как JSON, просто стримить binary с правильными headers.

Shared TypeScript types

  • shared/src/types/external-menu.ts:
export type ExternalMenuChannel = 'tv_screen' | 'json' | 'yandex_eda' | 'koala';
export type ExternalMenuStatus = 'draft' | 'published' | 'archived';
export type ExternalMenuTemplate = 'grid' | 'slider' | 'list';
export type ExternalMenuItemStatus = 'ok' | 'orphan';
 
export interface ExternalMenuSummary {
  id: string;
  name: string;
  channel: ExternalMenuChannel;
  store_id: string | null;
  store_name: string | null;
  template: ExternalMenuTemplate | null;
  slug: string;
  status: ExternalMenuStatus;
  live_url: string | null;
  created_at: string;
  updated_at: string;
  archived_at?: string | null;
}
 
export interface ExternalMenuCategory {
  id: string;
  name: string;
  original_category_id: string | null;
  display_order: number;
  icon_url: string | null;
}
 
export interface ExternalMenuItem {
  id: string;
  product_id: string;
  product_name: string;
  product_image_url: string | null;
  catalog_price: number;
  price_list_price: number | null;
  override_name: string | null;
  override_description: string | null;
  override_price: number | null;
  effective_price: number;
  visible: boolean;
  display_order: number;
  status: ExternalMenuItemStatus;
  in_stop_list: boolean;
}
 
export interface ExternalMenuDetails extends ExternalMenuSummary {
  categories: Array<ExternalMenuCategory & {
    items: ExternalMenuItem[];
  }>;
}
 
export interface CreateExternalMenuRequest {
  name: string;
  channel: ExternalMenuChannel;
  store_id?: string | null;
  template?: ExternalMenuTemplate | null;
  slug?: string | null;
}
 
export interface UpdateExternalMenuRequest {
  name?: string;
  template?: ExternalMenuTemplate;
  slug?: string;
  store_id?: string | null;
}
 
export interface CreateCategoryRequest {
  name: string;
  original_category_id?: string | null;
  icon_url?: string | null;
}
 
export interface UpdateCategoryRequest {
  name?: string;
  icon_url?: string | null;
}
 
export interface CreateItemRequest {
  product_id: string;
  category_id: string;
}
 
export interface UpdateItemRequest {
  override_name?: string | null;
  override_description?: string | null;
  override_price?: number | null;
  visible?: boolean;
  category_id?: string;
}
 
export interface ReorderRequest {
  order: Array<{ category_id?: string; item_id?: string; display_order: number }>;
  category_id?: string;  // для items reorder
}

API клиент в web

  • web/src/api/external-menus.ts — новый файл с функциями для всех endpoint’ов. По паттерну web/src/api/paykeeper.ts:
export const listExternalMenus = (params?: {...}) =>
  apiGet<ListResponse<ExternalMenuSummary>>('/api/v1/admin/external-menus', { params });
export const getExternalMenu = (id: string) =>
  apiGet<DataResponse<ExternalMenuDetails>>(`/api/v1/admin/external-menus/${id}`);
// ... и т.д. для всех 18 endpoint'ов

Тесты

  • BFF unit-тесты на критичные роуты (mock backend response, проверка форвардинга headers/body/status)
  • Тест /export.zip — проверка что binary стрим прокидывается с правильными headers без JSON-парсинга

Не делаем

  • ❌ Кеш на BFF — нет смысла (данные актуализируются live через WebSocket)
  • ❌ Бизнес-логика — BFF только проксирует
  • ❌ Загрузка фотографий через BFF — фотки уже в каталоге с image_url

Verification

  1. curl POST /api/v1/admin/external-menus с валидным JWT → создаётся меню в Catalog Service
  2. curl GET /api/v1/admin/external-menus/{id}/export.zip → скачивается binary ZIP
  3. Без JWT → 401 (BFF middleware)
  4. С JWT без external_menus.edit → 403 (от backend)

Ссылки