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. Используем существующийrequireAuthmiddleware и 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
curl POST /api/v1/admin/external-menusс валидным JWT → создаётся меню в Catalog Servicecurl GET /api/v1/admin/external-menus/{id}/export.zip→ скачивается binary ZIP- Без JWT → 401 (BFF middleware)
- С JWT без
external_menus.edit→ 403 (от backend)