From 350581cbe92ea22180fa5a578c49991ab581387f Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 28 Feb 2026 17:42:36 +0400 Subject: [PATCH] changes for md --- docs/API_CHANGES_REQUIRED.md | 736 ++++++++++++++---- docs/API_DOCS_RU.md | 726 +++++++++++++++++ src/app/app.config.ts | 3 +- .../interceptors/api-headers.interceptor.ts | 37 + 4 files changed, 1363 insertions(+), 139 deletions(-) create mode 100644 docs/API_DOCS_RU.md create mode 100644 src/app/interceptors/api-headers.interceptor.ts diff --git a/docs/API_CHANGES_REQUIRED.md b/docs/API_CHANGES_REQUIRED.md index fde7536..22dc479 100644 --- a/docs/API_CHANGES_REQUIRED.md +++ b/docs/API_CHANGES_REQUIRED.md @@ -1,24 +1,471 @@ -# API Changes Required for Backend +# Complete Backend API Documentation -## Overview - -Frontend has been updated with two new features: -1. **Region/Location system** — catalog filtering by region -2. **Auth/Login system** — Telegram-based authentication required before payment - -Base URLs: -- Dexar: `https://api.dexarmarket.ru:445` -- Novo: `https://api.novo.market:444` +> **Last updated:** February 2026 +> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo) +> **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice --- -## 1. Region / Location Endpoints +## Base URLs -### 1.1 `GET /regions` — List available regions +| Brand | Dev | Production | +|--------|----------------------------------|----------------------------------| +| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` | +| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` | -Returns the list of regions where the marketplace operates. +--- -**Response** `200 OK` +## Global HTTP Headers + +The frontend **automatically attaches** two custom headers to **every API request** via an interceptor. The backend should read these headers and use them to filter/translate responses accordingly. + +| Header | Example Value | Description | +|---------------|---------------|------------------------------------------------------------| +| `X-Region` | `moscow` | Region ID selected by the user. **Absent** = global (all). | +| `X-Language` | `ru` | Active UI language: `ru`, `en`, or `hy`. | + +### Backend behavior + +- **`X-Region`**: If present, filter items/categories to only those available in that region. If absent, return everything (global catalog). +- **`X-Language`**: If present, return translated `name`, `description`, etc. for categories/items when translations exist. If absent or `ru`, use russians defaults. + +### CORS requirements for these headers + +``` +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +``` + +--- + +## 1. Health Check + +### `GET /ping` + +Simple health check. + +**Response `200`:** +```json +{ "message": "pong" } +``` + +--- + +## 2. Catalog — Categories + +### `GET /category` + +Returns all top-level categories. Respects `X-Region` and `X-Language` headers. + +**Response `200`:** +```json +[ + { + "categoryID": 1, + "name": "Электроника", + "parentID": 0, + "icon": "https://...", + "wideBanner": "https://...", + "itemCount": 42, + "priority": 10, + + "id": "cat_abc123", + "visible": true, + "img": "https://...", + "projectId": "proj_xyz", + "subcategories": [ + { + "id": "sub_001", + "name": "Смартфоны", + "visible": true, + "priority": 5, + "img": "https://...", + "categoryId": "cat_abc123", + "parentId": "cat_abc123", + "itemCount": 20, + "hasItems": true, + "subcategories": [] + } + ] + } +] +``` + +**Category object:** + +| Field | Type | Required | Description | +|------------------|---------------|----------|----------------------------------------------------| +| `categoryID` | number | yes | Legacy numeric ID | +| `name` | string | yes | Category display name (translated if `X-Language`) | +| `parentID` | number | yes | Parent category ID (`0` = top-level) | +| `icon` | string | no | Category icon URL | +| `wideBanner` | string | no | Wide banner image URL | +| `itemCount` | number | no | Number of items in category | +| `priority` | number | no | Sort priority (higher = first) | +| `id` | string | no | BackOffice string ID | +| `visible` | boolean | no | Whether category is shown (`true` default) | +| `img` | string | no | BackOffice image URL (maps to `icon`) | +| `projectId` | string | no | BackOffice project reference | +| `subcategories` | Subcategory[] | no | Nested subcategories | + +**Subcategory object:** + +| Field | Type | Required | Description | +|------------------|---------------|----------|------------------------------------| +| `id` | string | yes | Subcategory ID | +| `name` | string | yes | Display name | +| `visible` | boolean | no | Whether visible | +| `priority` | number | no | Sort priority | +| `img` | string | no | Image URL | +| `categoryId` | string | yes | Parent category ID | +| `parentId` | string | yes | Direct parent ID | +| `itemCount` | number | no | Number of items | +| `hasItems` | boolean | no | Whether has any items | +| `subcategories` | Subcategory[] | no | Nested children | + +--- + +### `GET /category/:categoryID` + +Returns items in a specific category. Respects `X-Region` and `X-Language` headers. + +**Query params:** + +| Param | Type | Default | Description | +|----------|--------|---------|--------------------| +| `count` | number | `50` | Items per page | +| `skip` | number | `0` | Offset for paging | + +**Response `200`:** Array of [Item](#item-object) objects. + +--- + +## 3. Items + +### `GET /item/:itemID` + +Returns a single item. Respects `X-Region` and `X-Language` headers. + +**Response `200`:** A single [Item](#item-object) object. + +--- + +### `GET /searchitems` + +Full-text search across items. Respects `X-Region` and `X-Language` headers. + +**Query params:** + +| Param | Type | Default | Description | +|----------|--------|---------|----------------------| +| `search` | string | — | Search query (required) | +| `count` | number | `50` | Items per page | +| `skip` | number | `0` | Offset for paging | + +**Response `200`:** +```json +{ + "items": [ /* Item objects */ ], + "total": 128, + "count": 50, + "skip": 0 +} +``` + +--- + +### `GET /randomitems` + +Returns random items for carousel/recommendations. Respects `X-Region` and `X-Language` headers. + +**Query params:** + +| Param | Type | Default | Description | +|------------|--------|---------|------------------------------------| +| `count` | number | `5` | Number of items to return | +| `category` | number | — | Optional: limit to this category | + +**Response `200`:** Array of [Item](#item-object) objects. + +--- + +### Item Object + +The backend can return items in **either** legacy format or BackOffice format. The frontend normalizes both. + +```json +{ + "categoryID": 1, + "itemID": 123, + "name": "iPhone 15 Pro", + "photos": [{ "url": "https://..." }], + "description": "Описание товара", + "currency": "RUB", + "price": 89990, + "discount": 10, + "remainings": "high", + "rating": 4.5, + "callbacks": [ + { + "rating": 5, + "content": "Отличный товар!", + "userID": "user_123", + "timestamp": "2026-02-01T12:00:00Z" + } + ], + "questions": [ + { + "question": "Есть ли гарантия?", + "answer": "Да, 12 месяцев", + "upvotes": 5, + "downvotes": 0 + } + ], + + "id": "item_abc123", + "visible": true, + "priority": 10, + "imgs": ["https://img1.jpg", "https://img2.jpg"], + "tags": ["new", "popular"], + "badges": ["bestseller", "sale"], + "simpleDescription": "Краткое описание", + "descriptionFields": [ + { "key": "Процессор", "value": "A17 Pro" }, + { "key": "Память", "value": "256 GB" } + ], + "subcategoryId": "sub_001", + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short description", + "description": [ + { "key": "Processor", "value": "A17 Pro" } + ] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կարcheck check check" + } + }, + "comments": [ + { + "id": "cmt_001", + "text": "Отличный товар!", + "author": "user_123", + "stars": 5, + "createdAt": "2026-02-01T12:00:00Z" + } + ], + "quantity": 50 +} +``` + +**Full Item fields:** + +| Field | Type | Required | Description | +|---------------------|-------------------|----------|------------------------------------------------------------| +| `categoryID` | number | yes | Category this item belongs to | +| `itemID` | number | yes | Legacy numeric item ID | +| `name` | string | yes | Item display name | +| `photos` | Photo[] | no | Legacy photo array `[{ url }]` | +| `description` | string | yes | Text description | +| `currency` | string | yes | Currency code (default: `RUB`) | +| `price` | number | yes | Price in the currency's smallest display unit | +| `discount` | number | yes | Discount percentage (`0`–`100`) | +| `remainings` | string | no | Stock level: `high`, `medium`, `low`, `out` | +| `rating` | number | yes | Average rating (`0`–`5`) | +| `callbacks` | Review[] | no | Legacy reviews (alias for reviews) | +| `questions` | Question[] | no | Q&A entries | +| `id` | string | no | BackOffice string ID | +| `visible` | boolean | no | Whether item is visible (`true` default) | +| `priority` | number | no | Sort priority (higher = first) | +| `imgs` | string[] | no | BackOffice image URLs (maps to `photos`) | +| `tags` | string[] | no | Item tags for filtering | +| `badges` | string[] | no | Display badges (`bestseller`, `sale`, etc.) | +| `simpleDescription` | string | no | Short plain-text description | +| `descriptionFields` | DescriptionField[]| no | Structured `[{ key, value }]` descriptions | +| `subcategoryId` | string | no | BackOffice subcategory reference | +| `translations` | Record | no | Translations keyed by lang code (see below) | +| `comments` | Comment[] | no | BackOffice comments format | +| `quantity` | number | no | Numeric stock count (maps to `remainings` on frontend) | + +**Nested types:** + +| Type | Fields | +|--------------------|-----------------------------------------------------------------| +| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` | +| `DescriptionField` | `key: string`, `value: string` | +| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` | +| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` | +| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` | +| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` | + +--- + +## 4. Cart + +### `POST /cart` — Add item to cart + +**Request body:** +```json +{ "itemID": 123, "quantity": 1 } +``` + +**Response `200`:** +```json +{ "message": "Added to cart" } +``` + +--- + +### `PATCH /cart` — Update item quantity + +**Request body:** +```json +{ "itemID": 123, "quantity": 3 } +``` + +**Response `200`:** +```json +{ "message": "Updated" } +``` + +--- + +### `DELETE /cart` — Remove items from cart + +**Request body:** Array of item IDs +```json +[123, 456] +``` + +**Response `200`:** +```json +{ "message": "Removed" } +``` + +--- + +### `GET /cart` — Get cart contents + +**Response `200`:** Array of [Item](#item-object) objects (each with `quantity` field). + +--- + +## 5. Payments (SBP / QR) + +### `POST /cart` — Create payment (SBP QR) + +> Note: Same endpoint as add-to-cart but with different body schema. + +**Request body:** +```json +{ + "amount": 89990, + "currency": "RUB", + "siteuserID": "tg_123456789", + "siteorderID": "order_abc123", + "redirectUrl": "", + "telegramUsername": "john_doe", + "items": [ + { "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" } + ] +} +``` + +**Response `200`:** +```json +{ + "qrId": "qr_abc123", + "qrStatus": "CREATED", + "qrExpirationDate": "2026-02-28T13:00:00Z", + "payload": "https://qr.nspk.ru/...", + "qrUrl": "https://qr.nspk.ru/..." +} +``` + +--- + +### `GET /qr/payment/:qrId` — Check payment status + +**Response `200`:** +```json +{ + "additionalInfo": "", + "paymentPurpose": "Order #order_abc123", + "amount": 89990, + "code": "SUCCESS", + "createDate": "2026-02-28T12:00:00Z", + "currency": "RUB", + "order": "order_abc123", + "paymentStatus": "COMPLETED", + "qrId": "qr_abc123", + "transactionDate": "2026-02-28T12:01:00Z", + "transactionId": 999, + "qrExpirationDate": "2026-02-28T13:00:00Z", + "phoneNumber": "+7XXXXXXXXXX" +} +``` + +| `paymentStatus` values | Meaning | +|------------------------|---------------------------| +| `CREATED` | QR generated, not paid | +| `WAITING` | Payment in progress | +| `COMPLETED` | Payment successful | +| `EXPIRED` | QR code expired | +| `CANCELLED` | Payment cancelled | + +--- + +### `POST /purchase-email` — Submit email after payment + +**Request body:** +```json +{ + "email": "user@example.com", + "telegramUserId": "123456789", + "items": [ + { "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" } + ] +} +``` + +**Response `200`:** +```json +{ "message": "Email sent" } +``` + +--- + +## 6. Reviews / Comments + +### `POST /comment` — Submit a review + +**Request body:** +```json +{ + "itemID": 123, + "rating": 5, + "comment": "Great product!", + "username": "john_doe", + "userId": 123456789, + "timestamp": "2026-02-28T12:00:00Z" +} +``` + +**Response `200`:** +```json +{ "message": "Review submitted" } +``` + +--- + +## 7. Regions + +### `GET /regions` — List available regions + +Returns regions where the marketplace operates. + +**Response `200`:** ```json [ { @@ -46,52 +493,44 @@ Returns the list of regions where the marketplace operates. ``` **Region object:** -| Field | Type | Required | Description | -|---------------|--------|----------|------------------------------| -| `id` | string | yes | Unique region identifier | -| `city` | string | yes | City name (display) | -| `country` | string | yes | Country name (display) | -| `countryCode` | string | yes | ISO 3166-1 alpha-2 code | -| `timezone` | string | no | IANA timezone string | -> If this endpoint is unavailable, the frontend falls back to 6 hardcoded regions (Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi). +| Field | Type | Required | Description | +|---------------|--------|----------|--------------------------| +| `id` | string | yes | Unique region identifier | +| `city` | string | yes | City name (display) | +| `country` | string | yes | Country name | +| `countryCode` | string | yes | ISO 3166-1 alpha-2 | +| `timezone` | string | no | IANA timezone | + +> **Fallback:** If this endpoint is down, the frontend uses 6 hardcoded defaults: Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi. --- -### 1.2 Region Query Parameter on Existing Endpoints - -The following **existing** endpoints now accept an optional `?region=` query parameter: - -| Endpoint | Example | -|---------------------------------|----------------------------------------------| -| `GET /category` | `GET /category?region=moscow` | -| `GET /category/:id` | `GET /category/5?count=50&skip=0®ion=spb` | -| `GET /item/:id` | `GET /item/123?region=yerevan` | -| `GET /searchitems` | `GET /searchitems?search=phone®ion=moscow` | -| `GET /randomitems` | `GET /randomitems?count=5®ion=almaty` | - -**Behavior:** -- If `region` param is **present** → return only items/categories available in that region -- If `region` param is **absent** → return all items globally (current behavior, no change) -- The `region` value matches the `id` field from the `/regions` response - ---- - -## 2. Auth / Login Endpoints +## 8. Authentication (Telegram Login) Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None). -All auth endpoints must support CORS with `credentials: true`. +All auth endpoints must include `withCredentials: true` CORS support. -### 2.1 `GET /auth/session` — Check current session +### Auth flow -Called on every page load to check if the user has an active session. +``` +1. User clicks "Checkout" → not authenticated → login dialog shown +2. User clicks "Log in with Telegram" → opens https://t.me/{bot}?start=auth_{callback} +3. User starts the bot in Telegram +4. Bot sends user data → backend /auth/telegram/callback +5. Backend creates session → sets Set-Cookie +6. Frontend polls GET /auth/session every 3s +7. Session detected → dialog closes → checkout proceeds +``` -**Request:** -- Cookies: session cookie (set by backend) -- CORS: `withCredentials: true` +--- -**Response `200 OK`** (authenticated): +### `GET /auth/session` — Check current session + +**Request:** Cookies only (session cookie set by backend). + +**Response `200`** (authenticated): ```json { "sessionId": "sess_abc123", @@ -103,7 +542,7 @@ Called on every page load to check if the user has an active session. } ``` -**Response `200 OK`** (expired session): +**Response `200`** (expired): ```json { "sessionId": "sess_abc123", @@ -115,37 +554,29 @@ Called on every page load to check if the user has an active session. } ``` -**Response `401 Unauthorized`** (no session / invalid cookie): +**Response `401`** (no session): ```json -{ - "error": "No active session" -} +{ "error": "No active session" } ``` **AuthSession object:** -| Field | Type | Required | Description | -|------------------|---------|----------|------------------------------------------| -| `sessionId` | string | yes | Unique session ID | -| `telegramUserId` | number | yes | Telegram user ID | -| `username` | string? | no | Telegram @username (can be null) | -| `displayName` | string | yes | User display name (first_name + last_name) | -| `active` | boolean | yes | Whether session is currently valid | -| `expiresAt` | string | yes | ISO 8601 expiration datetime | + +| Field | Type | Required | Description | +|------------------|---------|----------|--------------------------------------------| +| `sessionId` | string | yes | Unique session ID | +| `telegramUserId` | number | yes | Telegram user ID | +| `username` | string? | no | Telegram @username (can be null) | +| `displayName` | string | yes | User display name (first + last) | +| `active` | boolean | yes | Whether session is valid | +| `expiresAt` | string | yes | ISO 8601 expiration datetime | --- -### 2.2 `GET /auth/telegram/callback` — Telegram bot auth callback +### `GET /auth/telegram/callback` — Telegram bot auth callback -This is the URL the Telegram bot redirects to after the user starts the bot. +Called by the Telegram bot after user authenticates. -**Flow:** -1. Frontend generates link: `https://t.me/{botUsername}?start=auth_{encodedCallbackUrl}` -2. User clicks → opens Telegram → starts the bot -3. Bot sends user data to this callback endpoint -4. Backend creates session, sets `Set-Cookie` header -5. Frontend polls `GET /auth/session` every 3 seconds to detect when session becomes active - -**Request** (from Telegram bot / webhook): +**Request body (from bot):** ```json { "id": 123456789, @@ -158,7 +589,7 @@ This is the URL the Telegram bot redirects to after the user starts the bot. } ``` -**Response:** Should set a session cookie and return: +**Response:** Must set a session cookie and return: ```json { "sessionId": "sess_abc123", @@ -167,93 +598,89 @@ This is the URL the Telegram bot redirects to after the user starts the bot. ``` **Cookie requirements:** -| Attribute | Value | Notes | -|------------|----------------|------------------------------------------| -| `HttpOnly` | `true` | Not accessible via JS | -| `Secure` | `true` | HTTPS only | + +| Attribute | Value | Notes | +|------------|----------------|--------------------------------------------| +| `HttpOnly` | `true` | Not accessible via JS | +| `Secure` | `true` | HTTPS only | | `SameSite` | `None` | Required for cross-origin (API ≠ frontend) | -| `Path` | `/` | | -| `Max-Age` | `86400` (24h) | Or as needed | -| `Domain` | API domain | | - -> **Important:** Since the API domain differs from the frontend domain, `SameSite=None` + `Secure=true` is required for the cookie to be sent cross-origin. +| `Path` | `/` | | +| `Max-Age` | `86400` (24h) | Or as needed | --- -### 2.3 `POST /auth/logout` — End session +### `POST /auth/logout` — End session -**Request:** -- Cookies: session cookie -- CORS: `withCredentials: true` -- Body: `{}` (empty) +**Request:** Cookies only, empty body `{}` -**Response `200 OK`:** +**Response `200`:** ```json -{ - "message": "Logged out" -} +{ "message": "Logged out" } ``` -Should clear/invalidate the session cookie. +Must clear/invalidate the session cookie. --- -## 3. CORS Configuration +### Session refresh -For auth cookies to work cross-origin, the backend CORS config must include: +The frontend re-checks the session **60 seconds before `expiresAt`**. If the backend supports sliding expiration, it can reset the cookie's `Max-Age` on each `GET /auth/session`. + +--- + +## 9. i18n / Translations + +The frontend supports 3 languages: **Russian (ru)**, **English (en)**, **Armenian (hy)**. + +The active language is sent via the `X-Language` HTTP header on every request. + +### What the backend should do with `X-Language` + +1. **Categories & items**: If `translations` field exists for the requested language, return the translated `name`, `description`, etc. OR the backend can apply translations server-side and return already-translated fields. + +2. **The `translations` field** on items (optional approach): + ```json + { + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short desc in English", + "description": [{ "key": "Processor", "value": "A17 Pro" }] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կarcheck check" + } + } + } + ``` + +3. **Recommended approach**: Read `X-Language` header and return the `name`/`description` in that language directly. If no translation exists, return the Russian default. + +--- + +## 10. CORS Configuration + +For auth cookies and custom headers to work, the backend CORS config must include: ``` -Access-Control-Allow-Origin: https://dexarmarket.ru (NOT *) +Access-Control-Allow-Origin: https://dexarmarket.ru (NOT wildcard *) Access-Control-Allow-Credentials: true -Access-Control-Allow-Headers: Content-Type, Authorization +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS ``` -> `Access-Control-Allow-Origin` **cannot** be `*` when `Allow-Credentials: true`. Must be the exact frontend origin. +> **Important:** `Access-Control-Allow-Origin` cannot be `*` when `Allow-Credentials: true`. Must be the exact frontend origin. -For Novo, also allow `https://novo.market`. +**Allowed origins:** +- `https://dexarmarket.ru` +- `https://novo.market` +- `http://localhost:4200` (dev) +- `http://localhost:4201` (dev, Novo) --- -## 4. Session Refresh Behavior - -The frontend automatically re-checks the session **60 seconds before `expiresAt`**. If the backend supports session extension (sliding expiration), it can re-set the cookie with a fresh `Max-Age` on every `GET /auth/session` call. - ---- - -## 5. Auth Gate — Checkout Flow - -The checkout button (`POST /cart` payment) now requires authentication: -- If the user is **not logged in** → frontend shows a Telegram login dialog instead of proceeding -- If the user **is logged in** → checkout proceeds normally -- The session cookie is sent automatically with the payment request - -No backend changes needed for the payment endpoint itself — just ensure it reads the session cookie if needed for order association. - ---- - -## Summary of New Endpoints - -| Method | Path | Purpose | Auth Required | -|--------|----------------------------|-----------------------------|---------------| -| `GET` | `/regions` | List available regions | No | -| `GET` | `/auth/session` | Check current session | Cookie | -| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (from bot) | -| `POST` | `/auth/logout` | End session | Cookie | - -## Summary of Modified Endpoints - -| Method | Path | Change | -|--------|-------------------|---------------------------------------| -| `GET` | `/category` | Added optional `?region=` param | -| `GET` | `/category/:id` | Added optional `?region=` param | -| `GET` | `/item/:id` | Added optional `?region=` param | -| `GET` | `/searchitems` | Added optional `?region=` param | -| `GET` | `/randomitems` | Added optional `?region=` param | - ---- - -## Telegram Bot Setup +## 11. Telegram Bot Setup Each brand needs its own bot: - **Dexar:** `@dexarmarket_bot` @@ -262,5 +689,38 @@ Each brand needs its own bot: The bot should: 1. Listen for `/start auth_{callbackUrl}` command 2. Extract the callback URL -3. Send the user's Telegram data (id, first_name, username, etc.) to that callback URL +3. Send the user's Telegram data (`id`, `first_name`, `username`, etc.) to that callback URL 4. The callback URL is `{apiUrl}/auth/telegram/callback` + +--- + +## Complete Endpoint Reference + +### New endpoints + +| Method | Path | Description | Auth | +|--------|---------------------------|----------------------------|----------| +| `GET` | `/regions` | List available regions | No | +| `GET` | `/auth/session` | Check current session | Cookie | +| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (bot) | +| `POST` | `/auth/logout` | End session | Cookie | + +### Existing endpoints + +| Method | Path | Description | Auth | Headers | +|----------|-----------------------|-------------------------|------|--------------------| +| `GET` | `/ping` | Health check | No | — | +| `GET` | `/category` | List categories | No | X-Region, X-Language | +| `GET` | `/category/:id` | Items in category | No | X-Region, X-Language | +| `GET` | `/item/:id` | Single item | No | X-Region, X-Language | +| `GET` | `/searchitems` | Search items | No | X-Region, X-Language | +| `GET` | `/randomitems` | Random items | No | X-Region, X-Language | +| `POST` | `/cart` | Add to cart / Payment | No* | — | +| `PATCH` | `/cart` | Update cart quantity | No* | — | +| `DELETE` | `/cart` | Remove from cart | No* | — | +| `GET` | `/cart` | Get cart contents | No* | — | +| `POST` | `/comment` | Submit review | No | — | +| `GET` | `/qr/payment/:qrId` | Check payment status | No | — | +| `POST` | `/purchase-email` | Submit email after pay | No | — | + +> \* Cart/payment endpoints may use the session cookie if available for order association, but don't strictly require auth. The frontend enforces auth before checkout. diff --git a/docs/API_DOCS_RU.md b/docs/API_DOCS_RU.md new file mode 100644 index 0000000..f04007f --- /dev/null +++ b/docs/API_DOCS_RU.md @@ -0,0 +1,726 @@ +# Полная документация Backend API + +> **Последнее обновление:** Февраль 2026 +> **Фронтенд:** Angular 21 · Два бренда (Dexar + Novo) +> **Охватывает:** Каталог, Корзина, Оплата, Отзывы, Регионы, Авторизация, i18n, BackOffice + +--- + +## Базовые URL + +| Бренд | Dev | Production | +|--------|----------------------------------|----------------------------------| +| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` | +| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` | + +--- + +## Глобальные HTTP-заголовки + +Фронтенд **автоматически добавляет** два кастомных заголовка к **каждому API-запросу** через interceptor. Бэкенд должен читать эти заголовки и использовать для фильтрации/перевода ответов. + +| Заголовок | Пример значения | Описание | +|---------------|-----------------|-------------------------------------------------------------------| +| `X-Region` | `moscow` | ID региона, выбранного пользователем. **Отсутствует** = все регионы. | +| `X-Language` | `ru` | Активный язык интерфейса: `ru`, `en` или `hy`. | + +### Поведение бэкенда + +- **`X-Region`**: Если присутствует — фильтровать товары/категории только по этому региону. Если отсутствует — возвращать всё (глобальный каталог). +- **`X-Language`**: Если присутствует — возвращать переведённые `name`, `description` и т.д., если переводы существуют. Если отсутствует или `ru` — возвращать на русском (по умолчанию). + +### Требования CORS для этих заголовков + +``` +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +``` + +--- + +## 1. Проверка состояния + +### `GET /ping` + +Простая проверка работоспособности. + +**Ответ `200`:** +```json +{ "message": "pong" } +``` + +--- + +## 2. Каталог — Категории + +### `GET /category` + +Возвращает все категории верхнего уровня. Учитывает заголовки `X-Region` и `X-Language`. + +**Ответ `200`:** +```json +[ + { + "categoryID": 1, + "name": "Электроника", + "parentID": 0, + "icon": "https://...", + "wideBanner": "https://...", + "itemCount": 42, + "priority": 10, + + "id": "cat_abc123", + "visible": true, + "img": "https://...", + "projectId": "proj_xyz", + "subcategories": [ + { + "id": "sub_001", + "name": "Смартфоны", + "visible": true, + "priority": 5, + "img": "https://...", + "categoryId": "cat_abc123", + "parentId": "cat_abc123", + "itemCount": 20, + "hasItems": true, + "subcategories": [] + } + ] + } +] +``` + +**Объект Category:** + +| Поле | Тип | Обязат. | Описание | +|------------------|---------------|---------|-----------------------------------------------------| +| `categoryID` | number | да | Числовой ID (legacy) | +| `name` | string | да | Название категории (переведённое если `X-Language`) | +| `parentID` | number | да | ID родительской категории (`0` = верхний уровень) | +| `icon` | string | нет | URL иконки категории | +| `wideBanner` | string | нет | URL широкого баннера | +| `itemCount` | number | нет | Количество товаров в категории | +| `priority` | number | нет | Приоритет сортировки (больше = выше) | +| `id` | string | нет | Строковый ID из BackOffice | +| `visible` | boolean | нет | Видима ли категория (по умолч. `true`) | +| `img` | string | нет | URL изображения из BackOffice (маппится на `icon`) | +| `projectId` | string | нет | Ссылка на проект в BackOffice | +| `subcategories` | Subcategory[] | нет | Вложенные подкатегории | + +**Объект Subcategory:** + +| Поле | Тип | Обязат. | Описание | +|------------------|---------------|---------|----------------------------------| +| `id` | string | да | ID подкатегории | +| `name` | string | да | Отображаемое название | +| `visible` | boolean | нет | Видима ли | +| `priority` | number | нет | Приоритет сортировки | +| `img` | string | нет | URL изображения | +| `categoryId` | string | да | ID родительской категории | +| `parentId` | string | да | ID прямого родителя | +| `itemCount` | number | нет | Количество товаров | +| `hasItems` | boolean | нет | Есть ли товары | +| `subcategories` | Subcategory[] | нет | Вложенные дочерние подкатегории | + +--- + +### `GET /category/:categoryID` + +Возвращает товары определённой категории. Учитывает заголовки `X-Region` и `X-Language`. + +**Query-параметры:** + +| Параметр | Тип | По умолч. | Описание | +|----------|--------|-----------|-----------------------| +| `count` | number | `50` | Товаров на страницу | +| `skip` | number | `0` | Смещение для пагинации | + +**Ответ `200`:** Массив объектов [Item](#объект-item). + +--- + +## 3. Товары + +### `GET /item/:itemID` + +Возвращает один товар. Учитывает заголовки `X-Region` и `X-Language`. + +**Ответ `200`:** Один объект [Item](#объект-item). + +--- + +### `GET /searchitems` + +Полнотекстовый поиск по товарам. Учитывает заголовки `X-Region` и `X-Language`. + +**Query-параметры:** + +| Параметр | Тип | По умолч. | Описание | +|----------|--------|-----------|-------------------------------| +| `search` | string | — | Поисковый запрос (обязателен) | +| `count` | number | `50` | Товаров на страницу | +| `skip` | number | `0` | Смещение для пагинации | + +**Ответ `200`:** +```json +{ + "items": [ /* объекты Item */ ], + "total": 128, + "count": 50, + "skip": 0 +} +``` + +--- + +### `GET /randomitems` + +Возвращает случайные товары для карусели/рекомендаций. Учитывает заголовки `X-Region` и `X-Language`. + +**Query-параметры:** + +| Параметр | Тип | По умолч. | Описание | +|------------|--------|-----------|--------------------------------------| +| `count` | number | `5` | Количество товаров | +| `category` | number | — | Ограничить данной категорией (опц.) | + +**Ответ `200`:** Массив объектов [Item](#объект-item). + +--- + +### Объект Item + +Бэкенд может возвращать товары в **любом** из двух форматов — legacy или BackOffice. Фронтенд нормализует оба варианта. + +```json +{ + "categoryID": 1, + "itemID": 123, + "name": "iPhone 15 Pro", + "photos": [{ "url": "https://..." }], + "description": "Описание товара", + "currency": "RUB", + "price": 89990, + "discount": 10, + "remainings": "high", + "rating": 4.5, + "callbacks": [ + { + "rating": 5, + "content": "Отличный товар!", + "userID": "user_123", + "timestamp": "2026-02-01T12:00:00Z" + } + ], + "questions": [ + { + "question": "Есть ли гарантия?", + "answer": "Да, 12 месяцев", + "upvotes": 5, + "downvotes": 0 + } + ], + + "id": "item_abc123", + "visible": true, + "priority": 10, + "imgs": ["https://img1.jpg", "https://img2.jpg"], + "tags": ["new", "popular"], + "badges": ["bestseller", "sale"], + "simpleDescription": "Краткое описание", + "descriptionFields": [ + { "key": "Процессор", "value": "A17 Pro" }, + { "key": "Память", "value": "256 GB" } + ], + "subcategoryId": "sub_001", + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short description", + "description": [ + { "key": "Processor", "value": "A17 Pro" } + ] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կարcheck check" + } + }, + "comments": [ + { + "id": "cmt_001", + "text": "Отличный товар!", + "author": "user_123", + "stars": 5, + "createdAt": "2026-02-01T12:00:00Z" + } + ], + "quantity": 50 +} +``` + +**Все поля Item:** + +| Поле | Тип | Обязат. | Описание | +|---------------------|-------------------|---------|-----------------------------------------------------------| +| `categoryID` | number | да | Категория, к которой принадлежит товар | +| `itemID` | number | да | Числовой ID товара (legacy) | +| `name` | string | да | Название товара | +| `photos` | Photo[] | нет | Массив фотографий `[{ url }]` (legacy) | +| `description` | string | да | Текстовое описание | +| `currency` | string | да | Код валюты (по умолч. `RUB`) | +| `price` | number | да | Цена | +| `discount` | number | да | Процент скидки (`0`–`100`) | +| `remainings` | string | нет | Уровень остатка: `high`, `medium`, `low`, `out` | +| `rating` | number | да | Средний рейтинг (`0`–`5`) | +| `callbacks` | Review[] | нет | Отзывы (legacy формат) | +| `questions` | Question[] | нет | Вопросы и ответы | +| `id` | string | нет | Строковый ID из BackOffice | +| `visible` | boolean | нет | Виден ли товар (по умолч. `true`) | +| `priority` | number | нет | Приоритет сортировки (больше = выше) | +| `imgs` | string[] | нет | URL картинок из BackOffice (маппится на `photos`) | +| `tags` | string[] | нет | Теги для фильтрации | +| `badges` | string[] | нет | Бейджи (`bestseller`, `sale` и т.д.) | +| `simpleDescription` | string | нет | Краткое текстовое описание | +| `descriptionFields` | DescriptionField[]| нет | Структурированное описание `[{ key, value }]` | +| `subcategoryId` | string | нет | Ссылка на подкатегорию из BackOffice | +| `translations` | Record | нет | Переводы по ключу языка (см. ниже) | +| `comments` | Comment[] | нет | Комментарии в формате BackOffice | +| `quantity` | number | нет | Числовое кол-во на складе (маппится на `remainings`) | + +**Вложенные типы:** + +| Тип | Поля | +|--------------------|-----------------------------------------------------------------| +| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` | +| `DescriptionField` | `key: string`, `value: string` | +| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` | +| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` | +| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` | +| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` | + +--- + +## 4. Корзина + +### `POST /cart` — Добавить товар в корзину + +**Тело запроса:** +```json +{ "itemID": 123, "quantity": 1 } +``` + +**Ответ `200`:** +```json +{ "message": "Added to cart" } +``` + +--- + +### `PATCH /cart` — Обновить количество товара + +**Тело запроса:** +```json +{ "itemID": 123, "quantity": 3 } +``` + +**Ответ `200`:** +```json +{ "message": "Updated" } +``` + +--- + +### `DELETE /cart` — Удалить товары из корзины + +**Тело запроса:** Массив ID товаров +```json +[123, 456] +``` + +**Ответ `200`:** +```json +{ "message": "Removed" } +``` + +--- + +### `GET /cart` — Получить содержимое корзины + +**Ответ `200`:** Массив объектов [Item](#объект-item) (каждый с полем `quantity`). + +--- + +## 5. Оплата (СБП / QR) + +### `POST /cart` — Создать платёж (СБП QR) + +> Примечание: Тот же эндпоинт что и добавление в корзину, но с другой схемой тела запроса. + +**Тело запроса:** +```json +{ + "amount": 89990, + "currency": "RUB", + "siteuserID": "tg_123456789", + "siteorderID": "order_abc123", + "redirectUrl": "", + "telegramUsername": "john_doe", + "items": [ + { "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" } + ] +} +``` + +**Ответ `200`:** +```json +{ + "qrId": "qr_abc123", + "qrStatus": "CREATED", + "qrExpirationDate": "2026-02-28T13:00:00Z", + "payload": "https://qr.nspk.ru/...", + "qrUrl": "https://qr.nspk.ru/..." +} +``` + +--- + +### `GET /qr/payment/:qrId` — Проверить статус оплаты + +**Ответ `200`:** +```json +{ + "additionalInfo": "", + "paymentPurpose": "Order #order_abc123", + "amount": 89990, + "code": "SUCCESS", + "createDate": "2026-02-28T12:00:00Z", + "currency": "RUB", + "order": "order_abc123", + "paymentStatus": "COMPLETED", + "qrId": "qr_abc123", + "transactionDate": "2026-02-28T12:01:00Z", + "transactionId": 999, + "qrExpirationDate": "2026-02-28T13:00:00Z", + "phoneNumber": "+7XXXXXXXXXX" +} +``` + +| Значение `paymentStatus` | Значение | +|--------------------------|------------------------------| +| `CREATED` | QR создан, не оплачен | +| `WAITING` | Оплата в процессе | +| `COMPLETED` | Оплата успешна | +| `EXPIRED` | QR-код истёк | +| `CANCELLED` | Оплата отменена | + +--- + +### `POST /purchase-email` — Отправить email после оплаты + +**Тело запроса:** +```json +{ + "email": "user@example.com", + "telegramUserId": "123456789", + "items": [ + { "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" } + ] +} +``` + +**Ответ `200`:** +```json +{ "message": "Email sent" } +``` + +--- + +## 6. Отзывы / Комментарии + +### `POST /comment` — Оставить отзыв + +**Тело запроса:** +```json +{ + "itemID": 123, + "rating": 5, + "comment": "Отличный товар!", + "username": "john_doe", + "userId": 123456789, + "timestamp": "2026-02-28T12:00:00Z" +} +``` + +**Ответ `200`:** +```json +{ "message": "Review submitted" } +``` + +--- + +## 7. Регионы + +### `GET /regions` — Список доступных регионов + +Возвращает регионы, в которых работает маркетплейс. + +**Ответ `200`:** +```json +[ + { + "id": "moscow", + "city": "Москва", + "country": "Россия", + "countryCode": "RU", + "timezone": "Europe/Moscow" + }, + { + "id": "spb", + "city": "Санкт-Петербург", + "country": "Россия", + "countryCode": "RU", + "timezone": "Europe/Moscow" + }, + { + "id": "yerevan", + "city": "Ереван", + "country": "Армения", + "countryCode": "AM", + "timezone": "Asia/Yerevan" + } +] +``` + +**Объект Region:** + +| Поле | Тип | Обязат. | Описание | +|---------------|--------|---------|----------------------------------| +| `id` | string | да | Уникальный идентификатор региона | +| `city` | string | да | Название города | +| `country` | string | да | Название страны | +| `countryCode` | string | да | Код страны ISO 3166-1 alpha-2 | +| `timezone` | string | нет | Часовой пояс IANA | + +> **Фоллбэк:** Если эндпоинт недоступен, фронтенд использует 6 захардкоженных значений: Москва, СПб, Ереван, Минск, Алматы, Тбилиси. + +--- + +## 8. Авторизация (вход через Telegram) + +Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None). + +Все auth-эндпоинты должны поддерживать CORS с `credentials: true`. + +### Процесс авторизации + +``` +1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа +2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback} +3. Пользователь запускает бота в Telegram +4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback +5. Бэкенд создаёт сессию → устанавливает Set-Cookie +6. Фронтенд опрашивает GET /auth/session каждые 3 секунды +7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается +``` + +--- + +### `GET /auth/session` — Проверить текущую сессию + +**Запрос:** Только cookie (сессионная cookie, установленная бэкендом). + +**Ответ `200`** (авторизован): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": true, + "expiresAt": "2026-03-01T12:00:00Z" +} +``` + +**Ответ `200`** (сессия истекла): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": false, + "expiresAt": "2026-02-27T12:00:00Z" +} +``` + +**Ответ `401`** (нет сессии): +```json +{ "error": "No active session" } +``` + +**Объект AuthSession:** + +| Поле | Тип | Обязат. | Описание | +|------------------|---------|---------|-------------------------------------------| +| `sessionId` | string | да | Уникальный ID сессии | +| `telegramUserId` | number | да | ID пользователя в Telegram | +| `username` | string? | нет | @username в Telegram (может быть null) | +| `displayName` | string | да | Отображаемое имя (имя + фамилия) | +| `active` | boolean | да | Действительна ли сессия | +| `expiresAt` | string | да | Дата истечения в формате ISO 8601 | + +--- + +### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота + +Вызывается Telegram-ботом после авторизации пользователя. + +**Тело запроса (от бота):** +```json +{ + "id": 123456789, + "first_name": "John", + "last_name": "Doe", + "username": "john_doe", + "photo_url": "https://t.me/i/userpic/...", + "auth_date": 1709100000, + "hash": "abc123def456..." +} +``` + +**Ответ:** Должен установить cookie сессии и вернуть: +```json +{ + "sessionId": "sess_abc123", + "message": "Authenticated successfully" +} +``` + +**Требования к cookie:** + +| Атрибут | Значение | Примечание | +|------------|----------------|-----------------------------------------------------| +| `HttpOnly` | `true` | Недоступна из JavaScript | +| `Secure` | `true` | Только HTTPS | +| `SameSite` | `None` | Обязательно для cross-origin (API ≠ фронтенд) | +| `Path` | `/` | | +| `Max-Age` | `86400` (24ч) | Или по необходимости | + +--- + +### `POST /auth/logout` — Завершить сессию + +**Запрос:** Только cookie, пустое тело `{}` + +**Ответ `200`:** +```json +{ "message": "Logged out" } +``` + +Должен очистить/инвалидировать cookie сессии. + +--- + +### Обновление сессии + +Фронтенд повторно проверяет сессию за **60 секунд до `expiresAt`**. Если бэкенд поддерживает скользящий срок действия (sliding expiration), можно обновлять `Max-Age` cookie при каждом вызове `GET /auth/session`. + +--- + +## 9. i18n / Переводы + +Фронтенд поддерживает 3 языка: **Русский (ru)**, **Английский (en)**, **Армянский (hy)**. + +Активный язык отправляется через HTTP-заголовок `X-Language` с каждым запросом. + +### Что бэкенд должен делать с `X-Language` + +1. **Категории и товары**: Если для запрошенного языка есть поле `translations`, вернуть переведённые `name`, `description` и т.д. ИЛИ бэкенд может применять переводы на стороне сервера и возвращать уже переведённые поля. + +2. **Поле `translations`** на товарах (опциональный подход): + ```json + { + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short desc in English", + "description": [{ "key": "Processor", "value": "A17 Pro" }] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կarcheck check" + } + } + } + ``` + +3. **Рекомендуемый подход**: Читать заголовок `X-Language` и возвращать `name`/`description` на этом языке напрямую. Если перевода нет — возвращать русский вариант по умолчанию. + +--- + +## 10. Настройка CORS + +Для работы auth-cookie и кастомных заголовков конфигурация CORS бэкенда должна включать: + +``` +Access-Control-Allow-Origin: https://dexarmarket.ru (НЕ wildcard *) +Access-Control-Allow-Credentials: true +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS +``` + +> **Важно:** `Access-Control-Allow-Origin` не может быть `*` при `Allow-Credentials: true`. Должен быть точный origin фронтенда. + +**Разрешённые origins:** +- `https://dexarmarket.ru` +- `https://novo.market` +- `http://localhost:4200` (dev) +- `http://localhost:4201` (dev, Novo) + +--- + +## 11. Настройка Telegram-бота + +Каждому бренду нужен свой бот: +- **Dexar:** `@dexarmarket_bot` +- **Novo:** `@novomarket_bot` + +Бот должен: +1. Слушать команду `/start auth_{callbackUrl}` +2. Извлечь callback URL +3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL +4. Callback URL: `{apiUrl}/auth/telegram/callback` + +--- + +## Полный справочник эндпоинтов + +### Новые эндпоинты + +| Метод | Путь | Описание | Авторизация | +|--------|---------------------------|---------------------------------|-------------| +| `GET` | `/regions` | Список доступных регионов | Нет | +| `GET` | `/auth/session` | Проверка текущей сессии | Cookie | +| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) | +| `POST` | `/auth/logout` | Завершение сессии | Cookie | + +### Существующие эндпоинты + +| Метод | Путь | Описание | Авт. | Заголовки | +|----------|-----------------------|---------------------------|------|--------------------| +| `GET` | `/ping` | Проверка состояния | Нет | — | +| `GET` | `/category` | Список категорий | Нет | X-Region, X-Language | +| `GET` | `/category/:id` | Товары категории | Нет | X-Region, X-Language | +| `GET` | `/item/:id` | Один товар | Нет | X-Region, X-Language | +| `GET` | `/searchitems` | Поиск товаров | Нет | X-Region, X-Language | +| `GET` | `/randomitems` | Случайные товары | Нет | X-Region, X-Language | +| `POST` | `/cart` | Добавить в корзину / Оплата | Нет* | — | +| `PATCH` | `/cart` | Обновить кол-во | Нет* | — | +| `DELETE` | `/cart` | Удалить из корзины | Нет* | — | +| `GET` | `/cart` | Содержимое корзины | Нет* | — | +| `POST` | `/comment` | Оставить отзыв | Нет | — | +| `GET` | `/qr/payment/:qrId` | Статус оплаты | Нет | — | +| `POST` | `/purchase-email` | Отправить email после оплаты | Нет | — | + +> \* Эндпоинты корзины/оплаты могут использовать cookie сессии (если есть) для привязки к заказу, но не требуют авторизации строго. Фронтенд проверяет авторизацию перед оформлением заказа. diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 773a15b..7495a10 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,6 +4,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { cacheInterceptor } from './interceptors/cache.interceptor'; +import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { provideServiceWorker } from '@angular/service-worker'; export const appConfig: ApplicationConfig = { @@ -15,7 +16,7 @@ export const appConfig: ApplicationConfig = { withInMemoryScrolling({ scrollPositionRestoration: 'top' }) ), provideHttpClient( - withInterceptors([cacheInterceptor]) + withInterceptors([apiHeadersInterceptor, cacheInterceptor]) ), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), diff --git a/src/app/interceptors/api-headers.interceptor.ts b/src/app/interceptors/api-headers.interceptor.ts new file mode 100644 index 0000000..0775e40 --- /dev/null +++ b/src/app/interceptors/api-headers.interceptor.ts @@ -0,0 +1,37 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { LocationService } from '../services/location.service'; +import { LanguageService } from '../services/language.service'; +import { environment } from '../../environments/environment'; + +/** + * Interceptor that attaches X-Region and X-Language headers + * to every outgoing request aimed at our API. + * + * The backend reads these headers to: + * - filter catalog by region + * - return translated content in the requested language + */ +export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => { + // Only attach headers to our own API requests + if (!req.url.startsWith(environment.apiUrl)) { + return next(req); + } + + const locationService = inject(LocationService); + const languageService = inject(LanguageService); + + const regionId = locationService.regionId(); // '' when global + const lang = languageService.currentLanguage(); // 'ru' | 'en' | 'hy' + + let headers = req.headers; + + if (regionId) { + headers = headers.set('X-Region', regionId); + } + if (lang) { + headers = headers.set('X-Language', lang); + } + + return next(req.clone({ headers })); +};