# Backend Integration — Master Specification Status: canonical. This is the single authoritative backend/API contract for this Angular multi-tenant marketplace platform. It supersedes and replaces `docs/BACKEND.md`, `docs/BACKEND-INTEGRATION.md` (old), `docs/BACKEND-INTEGRATION-PROMPT.md`, `docs/BACKEND-DIFF-VS-MAIN.md`, `docs/architecture/backend/Backend-Platform-API-Spec.md`, and the API-contract portions of `docs/BOOTSTRAP.md`. Those files are deleted; this document is where all of that content now lives, verified against the current source tree on branch `B2B`. Audience: a backend engineer implementing this platform's API with no other context, and any frontend engineer who needs the ground truth for what the client sends and expects. ## How to read this document Every endpoint, DTO, and behavior below is tagged with a status: - **CURRENT** — real HTTP call today, verified in code (file path cited). Shape is frozen; do not change it. - **PLANNED** — declared in the bootstrap model (`apiEndpoints.builder` / `apiEndpoints.backoffice`) or implied by a gateway interface, but not wired to real HTTP yet. Served today by a `*LocalGateway` (in-memory/localStorage/IndexedDB mock) bound via an Angular DI token. Implementing the real endpoint means writing one `*ApiGateway` class against the documented interface and rebinding the token — no UI change. - **FUTURE** — reserved contract only. Nothing exists client-side yet beyond a placeholder or a "coming soon" page. Speculative shape, subject to change once real requirements exist. Never mix these tags. If you can't find a field in the cited model file, it isn't real — ask rather than invent (see Assumptions below for the few places a judgment call was necessary). ## Assumptions (only where the source code was genuinely ambiguous) 1. **Bootstrap path.** Code call sites use `GET /bootstrap` relative to the resolved API base (`ApiConfigService.getBaseUrl()`), not `/api/v1/bootstrap`. This document uses `GET /bootstrap` throughout, consistent with `docs/architecture/backend/Backend-Platform-API-Spec.md`'s draft and every existing doc. If a backend team versions it, `GET /v1/bootstrap` is a compatible evolution — do not silently rename without a schema/version bump story (see §5). 2. **`builder/*` and `backoffice/*` path prefixes.** No literal string constant for these prefixes exists in code (the mock gateways never build a URL at all — they're pure in-memory/localStorage). The prefixes used throughout §6 follow the naming already established in `docs/BACKEND.md`/`docs/BACKEND-INTEGRATION-PROMPT.md` and match `ApiEndpointsConfig`'s `builder`/`backoffice` buckets (`src/app/shared/models/config/api-endpoints.model.ts`). Treat every concrete path in §6.7–§6.16 as a **PLANNED contract proposal**, not a verified literal string — the *shapes* (request/response bodies, field names, enums) are verified against the real TypeScript interfaces; the *paths* are a reasonable default you're free to adjust as long as `apiEndpoints.backoffice`/`apiEndpoints.builder` in the published bootstrap stays in sync with whatever you choose. 3. **Auth/payment endpoints are explicitly frozen** by ADR-010 and out of scope for change — documented here for completeness (§2, §6.2) but treat as read-only reference, not a request for redesign. --- ## Table of contents 1. [Architecture overview](#1-architecture-overview) 2. [Authentication](#2-authentication) 3. [Security](#3-security) 4. [Bootstrap](#4-bootstrap) 5. [API conventions](#5-api-conventions) 6. [Endpoints, by domain](#6-endpoints-by-domain) 7. [DTOs](#7-dtos) 8. [State machines](#8-state-machines) 9. [Validation](#9-validation) 10. [Media](#10-media) 11. [Errors](#11-errors) 12. [Localization](#12-localization) 13. [Caching](#13-caching) 14. [Backend replacement pattern](#14-backend-replacement-pattern) 15. [Future APIs](#15-future-apis) 16. [Sequence diagrams](#16-sequence-diagrams) 17. [Developer notes](#17-developer-notes) --- ## 1. Architecture overview This is not a single marketplace — it is a multi-tenant platform. One Angular codebase serves unlimited tenants ("marketplaces"), each configured entirely at runtime from a backend-served `BootstrapConfig` document. No marketplace-specific code exists in the frontend (`docs/context/adrs/ADR-0001-marketplace-platform-vision.md`). Every tenant has three logical surfaces sharing one domain: - **Website** — the public storefront (catalog, product, cart, checkout-adjacent flows). - **Builder** (Project Editor) — the tenant admin's visual editor for the same `BootstrapConfig` the storefront renders from. No parallel model. - **Backoffice** — operational admin: products, categories, orders, transactions, users, monitoring, analytics, media. ```mermaid flowchart TD Host["HTTP Host header"] --> TenantResolve["Tenant resolution (backend, before auth/routing)"] TenantResolve --> Bootstrap["GET /bootstrap"] Bootstrap --> ConfigService["ConfigService (Angular, loads once, shareReplay(1))"] ConfigService --> Runtime["PlatformRuntimeService (theme, branding, nav, pages)"] Runtime --> Website["Website (public storefront)"] Runtime --> Builder["Builder / Project Editor (draft BootstrapConfig)"] Runtime --> Backoffice["Backoffice (admin CRUD)"] Website --> API["Storefront REST API (category/items/search/cart/reviews)"] Builder --> BuilderAPI["Builder REST API (draft/publish)"] Backoffice --> BackofficeAPI["Backoffice REST API (per-domain CRUD)"] API --> DB[("Database")] BuilderAPI --> DB BackofficeAPI --> DB ``` ### Request flow (every storefront call) 1. Application code calls `this.http.get('/api/category')` or builds an absolute URL via `ApiConfigService`. 2. **`apiBaseUrlInterceptor`** (`src/app/interceptors/api-base-url.interceptor.ts`) rewrites any `/api/...` URL to the resolved tenant base via `ApiConfigService.toApiUrl()`. 3. **`apiHeadersInterceptor`** (`src/app/interceptors/api-headers.interceptor.ts`) attaches `X-Region`, `X-Language`, `Currency`, `WebSessionID` to every request whose URL resolves as an API request (`ApiConfigService.isApiRequest()`). 4. **`adminAuthHeadersInterceptor`** (`src/app/core/admin-auth/admin-auth-headers.interceptor.ts`) additionally attaches `AdminWebSessionID` and, if present, `Authorization: Bearer ` — but only when the URL contains `/admin/`. 5. **`cacheInterceptor`** (`src/app/interceptors/cache.interceptor.ts`) short-circuits GET requests to `/category`, `/category/:id`, `/items/:id` against an in-memory TTL cache (see §13). 6. **`mockDataInterceptor`** (`src/app/interceptors/mock-data.interceptor.ts` / `.production.ts`) — dev-only; when `environment.useMockData` is true, intercepts before any of the above and serves fixture JSON. Not part of the real contract; ignore for backend implementation. 7. Request reaches the resolved absolute base (see base-URL resolution below). Interceptor registration order (as configured, `mockData → apiBaseUrl → apiHeaders → adminAuth → cache`) matters: base-URL rewriting must happen before headers are attached (headers only attach to recognized API requests), and admin headers only attach to `/admin/` URLs so customer requests never carry an `AdminWebSessionID`. ### Base-URL resolution (`ApiConfigService.getBaseUrl`, `src/app/core/config/api-config.service.ts`) Priority order, first match wins: 1. **Localhost** → `environment.localhostApiUrl` (`/api`, proxied by the Angular dev server). 2. **`environment.tenantApiBaseUrls[tenantKey]`** — explicit per-tenant override map (e.g. `default`/`dexarmarket` → `https://api.dexarmarket.ru:445`). 3. **`environment.tenantApiTemplate`** — host-pattern template, e.g. `https://{tenant}.api.dexarmarket.ru:445`. 4. **Bootstrap override** (opt-in only) — `bootstrap.apiEndpoints.website.baseUrl` or `bootstrap.tenant.apiBaseUrl`, used only when `environment.allowBootstrapApiOverride === true` **and** the value is an absolute `http(s)://` URL. Disabled by default in both `environment.ts` and `environment.production.ts`. 5. **Fallback** → `environment.apiUrl`. Tenant identity itself comes from `TenantResolverService` (host-based), never a path or query parameter — the backend must resolve tenant from the `Host` header the same way, before authorization and routing (mirrors `docs/architecture/backend/Backend-Platform-API-Spec.md` §1). **Backend impact for a new tenant:** either register it in `tenantApiBaseUrls`, or serve it at the `tenantApiTemplate` host pattern — no frontend rebuild required if the template host pattern holds. ### Layered feature architecture (why the UI never touches an endpoint directly) Every admin domain (categories, products, orders, transactions, users, monitoring, dashboard) follows: `*Gateway` interface → `*LocalGateway` (mock, bound today) / `*ApiGateway` (real, to be built) → DI token → Facade → Page components. See §14 for the full replacement mechanics. This is why implementing a backend for any one domain is additive: write one class, rebind one token, ship. --- ## 2. Authentication **Scope note (ADR-010):** authentication, payment, and authorization *behavior and contracts* are frozen — this section documents the existing contract for completeness and integration correctness. It is not a request to redesign the auth flow. ### 2.1 Why Telegram, why sessions, why not just JWT-on-login The platform authenticates end users (both customers and admins) via **Telegram**, not a password. A user scans a QR code (desktop) or taps a deep link (mobile) that opens the platform's Telegram bot with a `start` payload equal to a server-generated session id. The bot, on the backend side, associates that Telegram identity with the session id. The frontend never sees Telegram credentials — it only polls "is this session id now associated with a logged-in Telegram user?" This is why the flow is **session-first**: the session id exists *before* the user is authenticated, and authentication is something that happens to an already-existing session, not something that produces one. **Why not a JWT immediately on session creation:** the session id is created anonymously (no identity yet) so the frontend has something to encode into a QR code before any identity exists. A JWT would need a subject; there isn't one until the Telegram bot round-trip completes. **Why sessions (cookie) rather than only a bearer token in memory:** the customer and admin session ids are stored as cookies (`webSessionID`, `adminSessionID`) specifically so a page refresh or new tab doesn't lose login state — a pure in-memory token would not survive navigation. See §2.6 for exact cookie policy. **Why Telegram alone isn't enough for admin:** completing the Telegram QR flow only proves "this is a real Telegram account holder." It proves nothing about whether that account is *authorized* to act as a marketplace admin. See §2.5 — this is the platform's most significant open security gap today. ### 2.2 The one session API (CURRENT) Both customer and admin login are, today, **the same backend endpoint**: `{authApiUrl}/users/sessions` (`environment.authApiUrl`, e.g. `https://users.vitanova.network:456`), called exclusively through `TelegramSessionApiService` (`src/app/services/telegram-session-api.service.ts`). There is no separate admin session endpoint. | Method | Endpoint | Purpose | Caller | |---|---|---|---| | `POST` | `{authApiUrl}/users/sessions` | Create an anonymous session, get the Telegram bot deep-link | `TelegramSessionApiService.createSession()` | | `GET` | `{authApiUrl}/users/sessions/:webSessionID` | Poll session status (has the Telegram bot round-trip completed?) | `TelegramSessionApiService.checkSessionOnce()` | | `DELETE` | `{authApiUrl}/users/sessions/:webSessionID` | Logout / invalidate session | `TelegramSessionApiService.logout()` | **`POST /users/sessions`** — request body `{ webSessionID: string }` (a client-generated GUID, `generateGuid()` from `src/app/shared/util/guid.util.ts`), header `WebSessionID: `. Response is normalized defensively (`normalizeWebSession`) against many possible field-name variants — the client tolerates `webSessionID`/`WebSessionID`/`webSessionId`/`sessionID`/`SessionID`/`sessionId`/`id`/`ID` for the session id field, and similar variant lists for `user`, `status`/`active`/`loggedIn`, `username`, `firstName`/`first_name`, `expiresAt`/`expires`. **Backend implementers: pick one canonical field name per concept; the client's tolerance is defensive, not a suggestion to send multiple.** The frontend generates the QR/deep-link URL itself (not from a backend field): `https://t.me/{telegramBot}?start={webSessionID}` for the QR/browser flow, `tg://resolve?domain={telegramBot}&start={webSessionID}` for the in-app deep link. `telegramBot` is a frontend environment constant (`environment.telegramBot`, e.g. `myAMLKYCBOT` / `DexarSupport_bot`), not backend-supplied. **`GET /users/sessions/:webSessionID`** — polled every 5 seconds (`POLL_INTERVAL_MS`, `QrLoginEngine`, `src/app/shared/qr-login/qr-login.engine.ts`), up to 100 polls (`MAX_POLLS`) before the client gives up and shows `expired`. Also re-checked on `visibilitychange`/`focus`/`pageshow` events (the "user returned from the Telegram app" recovery path — `openAppLogin()` navigates away via `window.location.href`, so the SPA needs to re-verify session state whenever the tab regains focus). Response shape normalizes to: ```ts // src/app/shared/models/auth.model.ts (AuthSession) interface AuthSession { sessionId: string; userId: number | null; username: string | null; displayName: string; active: boolean; expires: string; // ISO timestamp } ``` If the backend response omits an expiry, the client defaults to `now + 3600s` (`SESSION_MAX_AGE_SECONDS = 60 * 60`) — this is a client-side fallback only; the backend should always send a real `expiresAt`. **`DELETE /users/sessions/:webSessionID`** — best-effort; client clears local state regardless of response (`catchError(() => of(null))`), so a network failure on logout never blocks the UI from appearing logged out. ### 2.3 Customer login (CURRENT) `AuthService` (`src/app/services/auth.service.ts`) wraps `TelegramSessionApiService` and owns: - Cookie `webSessionID`, `SameSite=Lax`, `Max-Age=3600`, `Secure` when served over HTTPS, `Path=/`. - A `status` signal: `unknown | checking | authenticated | unauthenticated`. - Auto session-refresh: schedules a re-check 60 seconds before `expires` (minimum 30s out), so an active tab silently re-validates before expiry rather than surprising the user with a sudden logout. - On app init, always calls `checkSession()` once against whatever cookie is present. ### 2.4 Admin login (CURRENT, same backend as customer) `AdminAuthService` (`src/app/core/admin-auth/admin-auth.service.ts`) is a **structurally identical** wrapper around the same `TelegramSessionApiService` — same `createSession`/`checkSessionOnce`/`logout` calls, same polling engine (`QrLoginEngine`) — with only the *storage* kept separate: | | Customer (`AuthService`) | Admin (`AdminAuthService`) | |---|---|---| | Cookie name | `webSessionID` | `adminSessionID` | | Cookie `SameSite` | `Lax` | `Strict` | | Extra token storage | — | `localStorage: adminToken` / `adminRefreshToken` (reserved for a future JWT pair, currently unused — see below) | | Guard | (route-level, customer flows) | `adminAuthGuard` (`src/app/core/admin-auth/admin-auth.guard.ts`) | | Header interceptor | `apiHeadersInterceptor` sets `WebSessionID` | `adminAuthHeadersInterceptor` sets `AdminWebSessionID` (+ `Authorization: Bearer` if a token exists) | **`AdminAuthService.getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()`** exist and are wired into the `Authorization` header, but nothing currently calls `setAdminTokens()` — this is dead-but-ready plumbing for once the backend issues a real JWT access/refresh pair on top of the Telegram session. Treat this as a **PLANNED** extension point, not a current contract. **Dev-only bypass:** `AdminAuthService.devBypassLogin()` fabricates a local session (`dev-bypass-`) and activates it directly, skipping the QR flow — but is a runtime no-op when `environment.production === true` (checked at call time, not just build time). Reachable via `?devBypassAdmin=true` per `docs/BACKEND-DIFF-VS-MAIN.md`. Not a backend concern beyond knowing it exists — it never calls any endpoint. ### 2.5 The admin authorization gap (CRITICAL — security-relevant, unresolved) Because admin and customer login hit the **identical** `POST /users/sessions` endpoint, **the backend currently has no way to know, at the moment the QR is scanned, that this is an admin login attempt versus a customer one.** The frontend's only decision is *where to store* the resulting session id (`adminSessionID` cookie vs. `webSessionID` cookie) — it cannot and does not decide, and cannot enforce, whether the Telegram user who completed the scan is actually authorized to act as an admin. **Concretely: any Telegram user who completes the QR flow while the admin login screen happens to be showing receives a valid `adminSessionID`.** There is currently no server-side check that rejects a non-admin Telegram user's session when it's used with `AdminWebSessionID`. **Required fix (server-side, not fixable from the frontend):** when an API call arrives carrying `AdminWebSessionID`, the backend must look up whether that Telegram user id is in the admin/role registry and reject (401/403) if not. This is a hard requirement before this system is production-safe for any tenant with real admin/customer separation. **Also prepared, not wired:** `Ed25519VerificationService` (`src/app/core/admin-auth/ed25519-verification.model.ts`) defines a `requestChallenge()`/`verify()` contract for a future non-Telegram, challenge/signature-based admin auth path (`Ed25519Challenge { nonce, timestamp, payload }` → `Ed25519SignedResponse { challenge, publicKey, signature }` → `Ed25519VerificationResult { valid, reason? }`). The current binding is `NoopEd25519VerificationService`, which **throws** rather than silently accepting anything — a deliberate fail-closed placeholder, safe to leave wired until a real challenge/verify endpoint exists. **FUTURE** — no endpoint exists for this today. ### 2.6 Cookies, headers, and session identity summary | Concern | Customer | Admin | |---|---|---| | Cookie | `webSessionID` | `adminSessionID` | | `SameSite` | `Lax` | `Strict` | | `Secure` | yes, when `https:` | yes, when `https:` | | `Max-Age` | 3600s | 3600s | | Header sent on API calls | `WebSessionID` (all API requests) | `AdminWebSessionID` (only `/admin/` requests) + optional `Authorization: Bearer ` | | Anonymous fallback | 32-char hex, `localStorage: web_session_id`, generated client-side if no session exists | none — unauthenticated admin routes redirect to login | ### 2.7 Refresh, expiry, unauthorized flows - **Refresh:** not a token-refresh in the OAuth sense — it's a periodic re-poll of `GET /users/sessions/:id` scheduled ~60s before the session's `expires` timestamp. If still active, the client silently re-activates the session (same cookie, updated in-memory signal); if not, the client transitions to `unauthenticated` and clears the cookie. - **Expired token flow:** `checkSessionOnce` returning `active: false` (or erroring) clears all local auth state (`clearAuthState('unauthenticated')`) — cookie removed, signals reset, refresh timer cancelled. No automatic re-login; the user must re-scan. - **Unauthorized flow (admin):** `adminAuthGuard` blocks navigation and calls `requestLogin()` (shows the login dialog) rather than a hard redirect — consistent with this being an SPA-level gate, not a route change. ### 2.8 Payments (frozen, documented for completeness) `ApiService` (`src/app/services/api.service.ts`) also exposes the payment surface — **frozen per ADR-010**, not to be changed: | Method | Endpoint | Notes | |---|---|---| | `createPayment()` | `POST {qrApiUrl}/qr` | QR dynamic payment creation. Headers `authorization-key`, `userid-value` optional. | | `createCartPayment()` | `POST /cart` | Cart-scoped payment (card or QR), fixed `partnerqrID = 'web-97ec-9c57-4dde-9037-3a68f7f83750'`. | | `checkCartPaymentStatus()` | `GET {qrApiUrl}/qr/dynamic/{partnerId}/:qrId` | | | `checkCartCardPaymentStatus()` | `GET {qrApiUrl}/card/{partnerId}/:orderId` | | | `checkPaymentStatus()` | `GET {qrApiUrl}/qr/dynamic/:partnerQrId/:qrId` | | `QrCreateRequest`/`QrCreateResponse`/`CartPaymentRequest`/`QrDynamicStatusResponse` interfaces are in `api.service.ts` lines 10–66 — reproduced in §7.9. The response normalizer tolerates many field-name casings (`qrId`/`qrID`, `nspkID`/`nspkId`, etc.) for the same reason as session responses: multiple backend versions/typos have shipped over time and the client absorbs them rather than breaking. --- ## 3. Security ### 3.1 Origin is not authentication The frontend sends no CSRF token and relies on cookies (`webSessionID`/`adminSessionID`, `SameSite=Lax`/`Strict`) plus header-based session ids (`WebSessionID`, `AdminWebSessionID`) as the actual authorization signal — **`Origin`/`Referer` headers must never be treated as an authentication mechanism.** They are trivially spoofable by any non-browser client (curl, a compromised browser extension, a malicious script running same-origin) and provide no proof of user identity or intent. Legitimate uses of `Origin` are limited to: (a) CORS allow-listing for browser-enforced preflight, and (b) defense-in-depth logging/anomaly detection — never as a substitute for verifying `WebSessionID`/`AdminWebSessionID` against a real, backend-held session record. ### 3.2 CORS Not configured in this repository (no `nginx.conf` CORS block for the actual API hosts `api.dexarmarket.ru:445` / `users.vitanova.network:456` — those are separate servers, not part of this repo). Recommended production posture: - Allow-list exact tenant storefront origins (from the tenant registry, not `*`). - `Access-Control-Allow-Credentials: true` (cookies are in play). - Preflight `OPTIONS` handling for any endpoint that receives custom headers (`X-Region`, `X-Language`, `Currency`, `WebSessionID`, `AdminWebSessionID`). ### 3.3 Endpoint authorization tiers | Tier | Examples | Requirement | |---|---|---| | Public | `GET /bootstrap`, `GET /category`, `GET /items/:id`, `GET /searchitems` | No auth. Never leak per-user/per-session data through these. | | Session-required (customer) | `POST /websession/:id` (cart), `POST /items/:id/callback` (review), `POST /purchase-email` | Valid `WebSessionID`; anonymous sessions are allowed (cart works for guests). | | JWT/admin-session-required | `builder/*`, `backoffice/*` | Valid `AdminWebSessionID` **and** (§2.5) server-side verification that the underlying Telegram identity is actually provisioned as an admin. | | Admin-role-required (finer-grained) | e.g. `users`/`roles` write endpoints vs. read-only `products` list | `AdminUser.roleId` → `AdminRole.permissions[]` (see §7.6) should gate specific mutations once a real permission model exists server-side. `PermissionsConfig` (`src/app/shared/models/config/permissions.model.ts`) is the bootstrap-level skeleton for this (`definitions: PermissionDefinition[]`, `roles: RolePermissions[]`) but is not yet enforced anywhere in the frontend beyond existing. ### 3.4 Rate limiting, replay protection, CSRF None of these exist today, client- or server-side, for this platform. Recommendations for a production backend: - **Rate limiting:** at minimum on `POST /users/sessions` (session creation is unauthenticated and cheap to hammer) and on write endpoints (`builder/bootstrap/publish`, any `backoffice/*` mutation). - **Replay protection:** session ids are long-lived (1 hour) bearer-equivalent values sent in a custom header — treat header leakage (e.g. via logs, proxies) as a real risk; short expiry + refresh (already the pattern, §2.7) is the primary mitigation. - **CSRF:** cookies are `SameSite=Lax` (customer) / `Strict` (admin), which meaningfully reduces cross-site request risk for the cookie itself, but the actual authorization value doubles as a **header** (`WebSessionID`/`AdminWebSessionID`) read from the cookie by JS and reattached — a state-changing endpoint that only checks the header, not the cookie, reopens CSRF risk from any origin that can trick a script into replaying a known session id. Recommend the backend validate that the header value matches a cookie-bound session, not just "does this header value exist in the sessions table." ### 3.5 Bearer tokens, HTTPS, headers - `Authorization: Bearer ` is sent when `AdminAuthService.getAdminToken()` returns non-null (currently always null in practice — see §2.4). Once real JWTs are issued, use standard `Authorization: Bearer` semantics; don't invent a custom scheme. - Every environment config uses `https://` for `apiUrl`/`authApiUrl`/`qrApiUrl` in production; only localhost dev proxies over `/api`. HTTPS is mandatory in production — cookies rely on `Secure` being meaningful. - Custom headers the backend must read: `X-Region`, `X-Language`, `Currency`, `WebSessionID` (all requests); `AdminWebSessionID`, `Authorization` (admin requests only). ### 3.6 Recommended production security checklist - Enforce admin authorization server-side per §2.5 — this is the single highest-priority item in this entire document. - Validate `WebSessionID`/`AdminWebSessionID` against a real backend-held session, never trust the header value alone as proof. - Rate-limit session creation and all write endpoints. - Serve `GET /bootstrap` with no secrets, ever (see §4) — it is public by design. - Populate `apiEndpoints.builder`/`apiEndpoints.backoffice` in bootstrap only with paths that are actually protected server-side; publishing an endpoint in bootstrap is not itself a security boundary. - CORS allow-list per tenant host, not wildcard, when credentials are involved. --- ## 4. Bootstrap ### 4.1 Endpoint **CURRENT** — `GET /bootstrap`, resolved against the tenant API base (§1). No query params or body. Tenant resolved server-side from the `Host` header — **the frontend never sends a tenant id.** Loaded once at app startup by `ConfigService.loadBootstrap()` (`src/app/core/config/config.service.ts`) and cached via `shareReplay(1)` — repeat calls to `loadBootstrap()` within the same session return the cached observable unless `forceRefresh: true` is passed. On localhost (or when `environment.useMockData === true`), `MockBootstrapProvider` serves `src/assets/mock/bootstrap/bootstrap.json` instead of a real HTTP call, so the app runs fully offline (`npm run dexar`). This mock file is the **canonical field-for-field reference payload** — match it exactly to avoid client-side normalization surprises. `ApiBootstrapProvider` is the real-HTTP implementation, both behind `CONFIG_PROVIDER` DI token per ADR-004. ### 4.2 Response caching No explicit `ETag`/`304` handling exists in the frontend today — the entire response is cached client-side for the session lifetime via `shareReplay(1)`. A backend implementer is free to add `ETag`/`Cache-Control` (recommended, see §13) since the client doesn't currently send conditional-request headers for this endpoint — adding them is additive and safe. ### 4.3 Top-level shape Source: `src/app/shared/models/config/bootstrap-config.model.ts`. ```ts interface BootstrapConfig { schemaVersion: string; generatedAt: string; tenant: TenantConfig; // required branding: BrandingConfig; // required theme: ThemeConfig; // required company: CompanyConfig; // required featureFlags: FeatureFlagsConfig; // required features?: MarketplaceFeaturesConfig; // optional, newer centralized feature surface apiEndpoints: ApiEndpointsConfig; // required localization: LocalizationConfig; // required seo: SeoConfig; // required permissions: PermissionsConfig; // required header?: HeaderConfig; catalog?: CatalogConfig; layout?: PlatformLayoutConfig; navigation: NavigationConfig; // required footer?: FooterConfig; productPage?: ProductPageConfig; userExperience?: UserExperienceConfig; pages: PageConfig[]; // required staticPages?: StaticPagesConfig; widgetRegistry?: WidgetRegistryConfig; } ``` **Rule (ADR-0001):** bootstrap contains only what's needed *before the app starts* — branding, theme, languages, navigation, footer/static-page references, homepage layout, enabled widgets/features. It must **never** contain products, orders, cart contents, or user-specific data. Product/category *data* is fetched separately (`GET /category`, `GET /items/:id`, etc.) — `catalog`/`productPage`/`userExperience` in bootstrap are feature/UI configuration only, never data. ### 4.4 Field-by-field reference | Key | Source file | Notes | |---|---|---| | `schemaVersion` | — | Contract version string. Breaking changes require a bump; frontend must stay compatible within a minor line (see §5.1). | | `generatedAt` | — | Payload generation timestamp, ISO 8601. | | `tenant` | `tenant.model.ts` | `id` (UUID), `slug`, `code`, `host`, `name`, `websiteBaseUrl`, `builderBaseUrl`, `backofficeBaseUrl`, `defaultLocale`, `supportedLocales[]`, `defaultCurrency`, `supportedCurrencies[]`, `timezone`. Resolved by domain only — never re-derived client-side. | | `branding` | `branding.model.ts` | `brandName`, `legalName`, `slogan?`, `logoUrl`, `logoCompactUrl?`, `faviconUrl`, `appIconUrl?`, `socialImageUrl?`, `galleryUrls?[]`, `supportEmail?`, `supportPhone?`. | | `theme` | `theme.model.ts` | `themeId`, `mode: light\|dark\|system`, `palette` (12 semantic colors: primary/secondary/accent/success/warning/danger/info/textPrimary/textSecondary/backgroundPrimary/backgroundSecondary/border), `typography` (`primaryFontFamily`, `headingFontFamily?`, `baseFontSize`), `spacing` (`unit`, `scale[]`), `borderRadiusScale: Record`, `shadows: Record`, `iconSet`. | | `company` | `company.model.ts` | `companyName`, `registrationNumber?`, `taxId?`, `address` (`country`, `region?`, `city`, `street?`, `postalCode?`), `contacts` (`email`, `phone?`, `telegram?`, `website?`, `additionalPhones?[]`, `additionalEmails?[]`). Used in footer legal/contact info. | | `featureFlags` | `feature-flags.model.ts` | Boolean map with 14 named keys (`wishlist`, `compare`, `reviews`, `questions`, `comments`, `recommendations`, `blog`, `chat`, `analytics`, `notifications`, `coupons`, `loyalty`, `giftCards`, `invoices`) plus an open `[key: string]: boolean` index — new flags can be added without a model change. | | `features` | `features-config.model.ts` | Newer, more granular `MarketplaceFeaturesConfig` (16 booleans: wishlist/compare/reviews/comments/questions/recommendations/recentlyViewed/searchHistory/recentlySearched/ratings/share/brands/manufacturers/availability/discounts/badges). Resolvers fall back to `featureFlags`/`productPage`/`userExperience`/`catalog` for older bootstraps lacking this field — **do not remove `featureFlags` when adding `features`; both must be sent for backward compatibility until every consumer is confirmed on the new surface.** | | `apiEndpoints` | `api-endpoints.model.ts` | `{ bootstrap: ApiEndpointConfig, website: Record, builder: Record, backoffice: Record }` where `ApiEndpointConfig = { path, method, timeoutMs? }`. **Never contains secrets.** Populate `builder`/`backoffice` buckets as you implement each endpoint so the frontend can (eventually) discover them. | | `localization` | `localization.model.ts` | `defaultLocale`, `supportedLocales[]`, `currencyByLocale: Record`, `dictionaries: LocalizationDictionaryRef[]` (`{locale, dictionaryUrl, version}`). | | `seo` | `seo.model.ts` | `default: SeoPageConfig`, `byPageKey: Record`. `SeoPageConfig = { title, description, canonicalUrl?, robots?, metaTags?: {name?, property?, content}[] }`. | | `permissions` | `permissions.model.ts` | `definitions: {key, description?}[]`, `roles: {role, permissions[]}[]`. Currently minimal/skeletal — real enforcement is server-side (§3.3, §2.5). | | `header` | `header-config.model.ts` | All-boolean toggles: `showLogo`, `showSearch`, `showCategories`, `showLanguages`, `showCart`, `showProfile`, `showWishlist`, `showCompare`, `showRegion`, plus `sticky?: boolean`, `layout?: 'default'\|'centered'`. | | `catalog` | `catalog-config.model.ts` | UI/feature config only, **no product data**. `layout` (8 grid/list modes), `loadingStrategy` (`pagination\|loadMore\|infiniteScroll`), `navigationMode` (4 modes), `defaultSort`/`availableSorts` (7 sort keys), `enabledFilters: string[]`, and 7 `show*`/`*Enabled` booleans. | | `layout` | `layout.model.ts` | `{ type: 'default'\|'sidebar-left'\|'carousel-home'\|'minimal'\|string, options?: Record }`. Global page-chrome mode. | | `navigation` | `navigation.model.ts` | `header: NavigationItemConfig[]`, `footer: NavigationItemConfig[] \| FooterNavigationGroupConfig[]` (two accepted shapes — flat list or grouped), optional `sidebar[]`. Each item: `id`, `label` (string or `{[locale]: string}`), `route?`, `type?`, `key?` (e.g. `'staticPage'` + a page id — see §12), `icon?`, `order?`, `visible?`, `visibleWhenFlags?: string[]`, `children?`. | | `footer` | `footer-config.model.ts` | `logoUrl?`, `paymentIcons?: {src, alt, width?, height?}[]`, `copyrightText?` (string or per-locale), `columns?: FooterColumnConfig[]` (`{id, title, links: {id,label,pageKey?,url?}[]}`), `socialLinks?: {id,label,url,icon?}[]`. `legalPageKeys`/`staticPageKeys` are **deprecated**, superseded by `columns` — still read for already-saved configs, do not emit them for new tenants. | | `productPage` | `product-page-config.model.ts` | Feature config only for PDP — `rating`, `reviews` (`pageSize`, `showSummary`, `mode: pages\|load-more`), `questions` (`pageSize`, `allowSubmission`), `tabs` (`items[]` from a fixed enum), `relatedProducts`, `actions` (`addToCart`,`buyNow`,`wishlist`,`compare`,`share`,`notifyMe`). No review/question *data* here. | | `userExperience` | `user-experience-config.model.ts` | Feature config only — `wishlist` (`headerBadgeEnabled`), `compare` (`maxItems`, `hideIdenticalDefault`, `highlightDifferencesDefault`), `recentlyViewed` (`maxItems`, `widgetEnabled`), `share`, `continueBrowsing`, `savedSearches` (`maxItems`). Never user-specific lists — those are client-local today (§6.13). | | `pages` | `page.model.ts` | `{id, key, title, route: {path, exact?}, layout: string \| PlatformLayoutConfig, sections: SectionConfig[], seoKey?, featureFlag?, visible?}`. | | `staticPages` | `static-page.model.ts` | See §4.6. | | `widgetRegistry` | `widget-registry.model.ts` | `{ manifestUrl: string }` — pointer to the widget manifest (see `docs/ARCHITECTURE.md` widget engine; not itself a backend endpoint documented here). | ### 4.5 Section and widget config ```ts // section.model.ts type SectionLayoutStrategy = 'stack' | 'grid' | 'hero' | 'carousel' | 'split'; interface SectionConfig { id: string; type: string; order: number; layout?: { strategy?: SectionLayoutStrategy; columns?: number; gap?: string; align?: 'start'|'center'|'end'|'stretch' }; visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean }; widgets: WidgetConfig[]; featureFlag?: string; visible?: boolean; } // widget.model.ts interface WidgetConfig { id: string; type: string; version: string; title?: string; subtitle?: string; order?: number; padding?: string; visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean }; animation?: { name?: string; duration?: string; delay?: string; timingFunction?: string }; style?: Record; permissions?: { requireAuthenticated?: boolean; roles?: string[]; permissions?: string[] }; props: Record; actions?: Record }>; featureFlag?: string; visible?: boolean; } ``` Typed editors exist in the Project Editor for `hero`, `categories`, `product-collection` widget types; every other type edits `props` as raw JSON. ### 4.6 `staticPages` ```ts // static-page.model.ts type StaticPagesConfig = Record | LegacyStaticPageConfig[]; // both accepted interface StaticPageConfig { id: string; slug: string; title: string | Record; showInFooter?: boolean; showInHeader?: boolean; showInSitemap?: boolean; icon?: string; order?: number; visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean }; requiresAuthentication?: boolean; footerGroup?: string; translations?: Record; html?: string | Record; seo?: StaticPageSeoConfig; visible?: boolean; route?: string; content?: Record; enabled?: boolean; // master on/off switch status?: 'draft' | 'published'; // per-page publish lifecycle, independent of whole-bootstrap publish customTemplate?: string; heroImage?: string; heroImageAlt?: string; heroImageCaption?: string; thumbnail?: string; gallery?: string[]; updatedAt?: string; } ``` **Resolution rule:** a static page resolves on the storefront only when `enabled === true` **and** `status === 'published'` — independent of whether the surrounding bootstrap has been published. A page with `status: 'draft'` stays invisible even after the whole tenant config is published. Compatibility default when normalizing legacy data (no `enabled`/`status` present): `enabled: true, status: 'published'` — existing tenants are never silently un-published. **Known data inconsistency:** the model requires `slug`, but the reference mock (`src/assets/mock/bootstrap/bootstrap.json`) leaves `slug` empty for some pages and only populates `route` (e.g. `/about-us`). The frontend's duplicate-detection falls back to `route` when `slug` is empty. **Decide and standardize:** either always populate `slug` server-side, or formally make `slug` derivable from `route` — do not leave both optional indefinitely. ### 4.7 Representative example (trimmed) ```json { "schemaVersion": "2.1.0", "generatedAt": "2026-07-05T10:30:00Z", "tenant": { "id": "tenant-dexar-ru", "slug": "dexar-ru", "code": "dexar-ru", "host": "dexarmarket.ru", "name": "Dexar Market", "websiteBaseUrl": "https://dexarmarket.ru", "builderBaseUrl": "https://dexarmarket.ru/edit", "backofficeBaseUrl": "https://dexarmarket.ru/admin", "defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"], "defaultCurrency": "RUB", "supportedCurrencies": ["RUB", "USD", "AMD"], "timezone": "Europe/Moscow" }, "branding": { "brandName": "Dexar Market", "legalName": "OOO Dexar", "logoUrl": "/assets/brand/logo.svg", "faviconUrl": "/assets/brand/favicon.ico" }, "theme": { "themeId": "dexar-light", "mode": "light", "palette": { "primary": "#2F6E5D", "secondary": "#8FA9A2", "accent": "#E7A33E", "success": "#2F9E44", "warning": "#F08C00", "danger": "#E03131", "info": "#1C7ED6", "textPrimary": "#1F322D", "textSecondary": "#5C6B66", "backgroundPrimary": "#FFFFFF", "backgroundSecondary": "#F4F6F5", "border": "#DDE3E1" }, "typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 }, "spacing": { "unit": 4, "scale": [0,4,8,12,16,24,32] }, "borderRadiusScale": { "sm": "6px", "md": "12px", "lg": "20px" }, "shadows": { "md": "0 4px 12px rgba(0,0,0,0.15)" }, "iconSet": "default" }, "company": { "companyName": "OOO Dexar", "address": { "country": "RU", "city": "Moscow" }, "contacts": { "email": "info@dexarmarket.ru", "phone": "+7 926 459 31 57" } }, "featureFlags": { "wishlist": true, "compare": true, "reviews": true, "questions": true, "comments": true, "recommendations": true, "blog": false, "chat": false, "analytics": true, "notifications": true, "coupons": false, "loyalty": false, "giftCards": false, "invoices": false }, "apiEndpoints": { "bootstrap": { "path": "/bootstrap", "method": "GET" }, "website": {}, "builder": {}, "backoffice": {} }, "localization": { "defaultLocale": "ru", "supportedLocales": ["ru","en","hy"], "currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" }, "dictionaries": [] }, "seo": { "default": { "title": "Dexar Market", "description": "Multi-category marketplace" }, "byPageKey": {} }, "permissions": { "definitions": [], "roles": [] }, "layout": { "type": "default" }, "catalog": { "layout": "grid-4", "navigationMode": "default", "defaultSort": "relevance", "showRatings": true }, "navigation": { "header": [ { "id": "nav-home", "label": "Home", "route": "/", "order": 1, "visible": true } ], "footer": [ { "id": "nav-privacy", "label": "Privacy", "route": "/privacy-policy", "order": 1, "visible": true } ] }, "pages": [ { "id": "page-home", "key": "home", "title": "Home", "route": { "path": "/", "exact": true }, "layout": "default", "sections": [ { "id": "home-hero", "type": "hero", "order": 1, "layout": { "strategy": "hero" }, "widgets": [ { "id": "w-hero", "type": "hero", "version": "1.0.0", "props": { "title": "Welcome", "layout": "full-bleed" } } ] }, { "id": "home-categories", "type": "categories", "order": 2, "layout": { "strategy": "grid", "columns": 4 }, "widgets": [ { "id": "w-categories", "type": "categories", "version": "1.0.0", "props": { "columns": 4 } } ] } ] } ], "staticPages": { "static-about": { "id": "static-about", "slug": "about", "title": { "en": "About Us" }, "showInFooter": true, "showInHeader": false, "enabled": true, "status": "published", "translations": { "en": { "html": "

About Us

" } } } } } ``` ### 4.8 Lifecycle: how the frontend builds itself from it 1. `ConfigService.loadBootstrap()` fetches (or, mock mode, reads local JSON) and parses `BootstrapConfig`. Cached via `shareReplay(1)`. 2. `PlatformRuntimeService` applies theme tokens as CSS variables, sets branding, and exposes parsed pages/navigation/footer/static-pages to the rest of the app. 3. The Section Engine / Widget Host render pages from `bootstrap.pages` on route match. 4. `PlatformRuntimeService.reloadFromBootstrap(next)` re-applies an entire new `BootstrapConfig` in-memory without a full page reload — this is what the Project Editor's Publish/Preview actions use (§6.7). The Project Editor edits an **in-memory copy of this exact same `BootstrapConfig`** — there is no parallel editor model or DTO translation layer (ADR-0001's core rule). --- ## 5. API conventions ### 5.1 Versioning No path-based versioning exists today (`/bootstrap`, not `/v1/bootstrap`) — `schemaVersion` inside the bootstrap payload is the actual contract version signal. Recommendation for new `builder/*`/`backoffice/*` endpoints: keep them unversioned in the path (matching existing convention) and rely on additive-only changes (new optional fields) within a schema-version line; bump `schemaVersion`'s major segment for breaking changes and coordinate a frontend release. ### 5.2 Naming and pluralization Existing storefront endpoints are **inconsistently pluralized** by historical accident (`/category` singular-collection, `/items/:id` plural-collection, `/searchitems` compound) — **do not "fix" this**, it's a frozen contract (§6.1). For all **new** `builder/*`/`backoffice/*` endpoints, use plural resource collections (`/backoffice/products`, `/backoffice/orders`) consistently — this document's proposed paths in §6.7–§6.16 already follow that convention. ### 5.3 Status codes | Code | Meaning | Used for | |---|---|---| | 200 | Success | GET, successful PUT/PATCH/POST that returns a body | | 201 | Created | POST that creates a resource (e.g. `POST /backoffice/products`) | | 204 | No Content | DELETE, or POST/PUT with no meaningful response body | | 400 | Bad Request | Malformed request (not a validation failure — see 422) | | 401 | Unauthorized | Missing/invalid session | | 403 | Forbidden | Valid session, insufficient role/permission (including the admin-authorization gap fix, §2.5) | | 404 | Not Found | Resource / tenant not found | | 409 | Conflict | Slug/SKU uniqueness violation, concurrent-edit conflict | | 422 | Unprocessable Entity | Validation failure (see §11 error shape) | | 429 | Too Many Requests | Rate limit | | 500 | Internal Server Error | Unhandled backend failure | | 503 | Service Unavailable | Maintenance mode, dependency down (see §11.3) | ### 5.4 Pagination Two patterns exist in the frontend's expectations, both real — pick per-domain, be consistent within a domain: - **Offset-based** (storefront): `count`/`skip` query params — `getCategoryItems(id, count=50, skip=0)`, `searchItems(..., count, skip)`, `getRandomItems(count)`. - **Page-based** (admin lists): `page`/`pageSize` in filter objects (`AdminProductListFilters`, `AdminOrderListFilters`, `AdminReviewListFilters`, `AdminTransactionListFilters` all carry `page: number; pageSize: number`), response shape `{ items: T[], total: number, page: number, pageSize: number }` (`AdminProductsListResult`, `AdminOrdersListResult`, etc. — this exact 4-field envelope repeats across every paginated admin list; treat it as the standard admin list-response envelope for any new domain too). ### 5.5 Sorting, filtering, searching - Storefront search (`GET /searchitems`) accepts `sort: relevance|price_asc|price_desc|popular|rating`, plus `categoryIDs` (comma-joined), `minPrice`, `maxPrice`, `tag`. - Admin product list sort (`AdminProductSort`): `title|price|priority|stock|updated`. - Every admin `*ListFilters` interface has a free-text `search: string` — treat it as a substring match across the domain's primary human-readable fields (name/title/email/order number as applicable) unless a domain-specific note says otherwise. ### 5.6 PATCH vs PUT, DELETE semantics - **PUT** — full-resource replace or explicit "save current state" (`PUT /builder/bootstrap/draft` — whole document). - **PATCH** — partial update by convention for status-only or single-field admin mutations (`updateStatus`, `setUserRole`, `setReviewVisible`, etc. — even though the gateway interfaces are transport-agnostic, model these as PATCH since they touch one field/aspect, not the whole entity). - **DELETE** — categories/products/orders all expose an explicit `delete*` method returning `Observable` (→ `204`), **separate from** `archive*`/`restore*` (soft delete — see below). Reviews (`deleteReview`) and orders (`deleteOrder`) also have hard-delete methods distinct from any archive flow — confirm with product/legal whether hard delete should really be permitted for orders (audit-trail implications) before implementing it as literal data removal versus a tombstone. ### 5.7 Soft delete Present as a first-class concept in two domains today: - **Categories** (`AdminCategory.deletedAt: string | null` + `restoreCategory(id)`). - **Products** (`archived: boolean` + `archiveProduct(id)`/`restoreProduct(id)` — boolean flag, not a timestamp). - **Orders** (`archived: boolean` + `archiveOrder(id)`/`restoreOrder(id)`). No universal convention between `deletedAt: string|null` and `archived: boolean` — both are real, keep them domain-consistent with what's documented in §7 rather than unifying speculatively. ### 5.8 Idempotency Not addressed anywhere in the current frontend contract. Recommend `Idempotency-Key` header support on `POST /builder/bootstrap/publish` and any payment-adjacent endpoint at minimum, since a retried publish/payment call must not double-apply. ### 5.9 Response/error format See §11 for the single standard error envelope. Success responses have no forced envelope — most endpoints return the resource (or resource array) directly, not wrapped in `{ success, data }`. Follow the shapes documented per-endpoint in §6, not a generic wrapper. ### 5.10 Dates, timezone, UUID - All timestamps in existing DTOs are ISO 8601 strings (`createdAt`, `updatedAt`, `expiresAt`, `generatedAt`, etc.) — never epoch numbers, except `AdminDashboardActivityEntry.timestamp: number` and `AdminMonitoringEvent`-adjacent internal caching (`Date.now()`), which are frontend-local, not wire-format precedent. - `TenantConfig.timezone` (IANA string, e.g. `Europe/Moscow`) exists but nothing currently converts times server-side based on it — treat display-timezone conversion as a frontend concern unless otherwise decided. - `TenantConfig.id: UUID` — `UUID`/`UrlString` are branded string types (`src/app/shared/types/primitive.types.ts`), i.e. plain strings at the wire level; no special serialization. ### 5.11 LocaleMap and translatable fields The platform-wide rule (ADR-0001): **all user-facing text is translatable via a `translations.{lang}` shape, never a flat hardcoded field.** This repeats across the codebase as `Record` keyed by locale code (`ru`/`en`/`hy` in this deployment, but the model itself is not hardcoded to those three): `AdminProduct.translations: Record`, `AdminCategory.translations: Record`, `StaticPageConfig.translations: Record`, `NavigationItemConfig.label: string | Record`, `FooterConfig.copyrightText: string | Record`. When adding/removing a supported locale, every translatable object must expose/drop that locale's key — generically, never per-field hardcoding. ### 5.12 Enums Enums throughout this document are TypeScript string-literal unions, not numeric — send/return the literal string values exactly as documented (e.g. `"processing"`, not `2`). --- ## 6. Endpoints, by domain ### 6.1 Storefront reads (CURRENT — frozen shapes, `src/app/services/api.service.ts`) `ApiService` normalizes both legacy and "backOffice" response variants defensively (dual field names, `0x`-hex colours → CSS hex, `names[]` → `translations`, `imgs[]` → `photos`, tolerance for the literal Go-side typo `valuue`). **Do not "fix" these variant shapes on the backend without confirming the frontend normalizer is updated in lockstep** — the normalizer exists precisely because multiple backend response shapes have shipped historically. | Method | Endpoint | Notes | |---|---|---| | `ping()` | `GET /ping` | Health check. | | `getCategories()` | `GET /category` | Retried ×2 with exponential backoff (`retry({count:2, delay: 2^n * 500ms})`). Cached client-side (§13). | | `getCategoryItems(id, count=50, skip=0)` | `GET /category/:id?count=&skip=` | Cached client-side. | | `getItem(id)` | `GET /items/:id` | Rating/reviews/questions derived from the item payload itself (embedded `comments`/`questions`), not a separate call. Cached client-side. | | `searchItems(search, count=50, skip=0, opts)` | `GET /searchitems?search=&count=&skip=&categoryIDs=&minPrice=&maxPrice=&tag=&sort=` | Returns `{ items: Item[], total: number }`. | | `getRandomItems(count=5, categoryID?)` | `GET /items/randomitems?count=&category=` | Used for featured/related product rails. | ### 6.2 Storefront writes (CURRENT — frozen shapes) | Method | Endpoint | Body | Response | |---|---|---|---| | `addToCart(sessionId, items)` | `POST /websession/:sessionId` | `Array<{itemID, quantity, colour?, size?, price?}>` | provider-defined | | `submitReview({...})` | `POST /items/:id/callback` | `{rating, comment, sessionID, timestamp}` | `{message: string}` | | `submitQuestion({...})` | `POST /items/:id/questiion` | `{question, sessionID, timestamp}` | `{message: string}` — **note the literal path typo `questiion`, keep it; it is a frozen contract, not a bug to fix.** | | `submitPurchaseEmail({...})` | `POST /purchase-email` | `{email, phone?, telegramUserId, items: [{itemID,name,price,currency,quantity?,delivery?}]}` | `{message: string}` | Payment endpoints: see §2.8. ### 6.3 Bootstrap — see §4 in full. ### 6.4 Authentication — see §2 in full. Summary table: | Method | Endpoint | Status | |---|---|---| | `POST {authApiUrl}/users/sessions` | Create session | CURRENT | | `GET {authApiUrl}/users/sessions/:id` | Poll/check session | CURRENT | | `DELETE {authApiUrl}/users/sessions/:id` | Logout | CURRENT | | Ed25519 challenge/verify | — | FUTURE (§2.5) | ### 6.5 Product engagement — rating / reviews / questions (CURRENT, per-product, confirm contract) The frontend already expects and renders against these; if they already exist on the backend, this is a contract-confirmation item, not new work: | Method | Endpoint | Notes | |---|---|---| | GET | `/products/{id}/rating` | Aggregate rating. | | GET | `/products/{id}/reviews?page=&pageSize=` | Paginated per `productPage.reviews.pageSize` config. | | GET | `/products/{id}/questions?page=&pageSize=` | Paginated per `productPage.questions.pageSize` config. | | POST | `/products/{id}/reviews` | `{rating, title, text, anonymous}`. | | POST | `/products/{id}/questions` | `{text, anonymous}`. | Toggled per-tenant via `bootstrap.productPage.reviews`/`.questions` (§4.4). If these don't exist yet, they block the Reviews/Questions UI. ### 6.6 Search / autocomplete / trending (PLANNED) Current: in-memory products/categories/tags power autocomplete (`SearchAutocompleteService`); trending returns `null` (UI hides gracefully, never fabricates data); search history is `LocalSearchHistoryRepository` (guest, localStorage) with a `BackendSearchHistoryRepository` **placeholder class already defined but unimplemented** (`src/app/features/search/services/search-history.repository.ts` — both classes exist side by side, only the backend one is a no-op today). | Method | Endpoint | Status | |---|---|---| | GET | `/search/suggestions?q={term}` | PLANNED | | GET | `/catalog/filters?category={id}&q={term}` | PLANNED | | GET/POST/DELETE | `/me/wishlist`, `/me/compare`, `/me/saved-searches`, `/me/recently-viewed` | PLANNED — only needed if authenticated cross-device sync of these lists is wanted; today they're 100% localStorage (`LocalUserExperienceRepository`, keys `marketplace.ux.wishlist` etc.) | Swapping any of these live requires only replacing the repository implementation — facade/UI unaffected by design. ### 6.7 Builder — Bootstrap draft/publish/validate (PLANNED, highest priority) The Project Editor edits the same `BootstrapConfig` the storefront reads (§4.8). Today: **load** is real (`GET /bootstrap`), **save** is `localStorage` only (`ProjectEditorDraftStorageService`, key `projectEditor.draftBootstrap.v1`, scoped by `tenant.id` — reload in another browser/tab and it's gone), **publish** re-applies the config in-memory via `PlatformRuntimeService.reloadFromBootstrap()` and flips a local `status` flag — **no backend call happens today.** `ProjectEditorIoService` today only does `JSON.stringify`/`JSON.parse` for manual export/import, no HTTP. | Method | Endpoint | Body | Response | Notes | |---|---|---|---|---| | GET | `/builder/bootstrap/draft` | — | `BootstrapConfig`, or 404/empty if none (draft = published) | Tenant resolved by host, no `projectId` param — one domain = one tenant = one draft = one published bootstrap. | | PUT | `/builder/bootstrap/draft` | `BootstrapConfig` | 200, updated draft metadata | Persists the draft. **Does not** affect `GET /bootstrap` (storefront-facing) until publish. | | POST | `/builder/bootstrap/publish` | `BootstrapConfig` (or none, if publish always promotes the stored draft) | 200 | Validates (§9.1), then makes this `BootstrapConfig` what `GET /bootstrap` returns. **Only** endpoint that affects the live storefront. | | POST | `/builder/bootstrap/validate` | `BootstrapConfig` | `ProjectValidationIssue[]` (see §9.1 for the exact shape) | Optional but recommended — mirrors the client validator so the editor can preview server-side issues before attempting publish. | **Mandatory:** server-side re-validation on publish, equivalent to `ProjectValidator` (§9.1) at minimum. The client validator is not a trust boundary — a malicious or stale client can bypass all client-side checks. Frontend files that change once this exists: `features/project-editor/facade/project-editor.facade.ts` (replace local save/publish with these endpoints, same public method signatures), `services/project-editor-draft-storage.service.ts` (becomes a fallback/offline cache, not the primary store). ### 6.8 Builder — Content pages / CMS (PLANNED) Static pages are today edited entirely client-side and written only into the in-memory/localStorage bootstrap draft (§6.7) — no dedicated backend exists. | Method | Endpoint | Notes | |---|---|---| | GET | `/builder/content-pages` | List, same shape as `bootstrap.staticPages`. | | PUT | `/builder/content-pages` | Replace the set. | | POST | `/builder/content-pages/import` | Bulk import. | | GET | `/builder/content-pages/export` | Bulk export. | | POST | `/builder/content-pages/validate` | Server-side HTML/SEO validation (§9.5). | Backend must also support **content moderation/validation on publish** (disallow dangerous tags/attributes) and **revision history** — the frontend only sanitizes at render time (`MarketplaceHtmlEditorComponent` emits raw HTML with no sanitization by design; sanitization is a storefront-render concern, not an authoring concern), never at authoring time. ### 6.9 Backoffice — Categories (PLANNED) Interface: `AdminCategoriesGateway` (`src/app/features/admin/categories/services/admin-categories-gateway.interface.ts`). Today: `AdminCategoriesLocalGateway`, in-memory, seeded once from `BackofficeDataService.loadCategories()` — nothing persists across reload. `GET /category` (§6.1, storefront-facing) is unaffected and stays as-is. ```ts interface AdminCategoriesGateway { loadCategories(filters: AdminCategoryListFilters): Observable; loadCategory(id: string): Observable; createCategory(category: AdminCategory): Observable; updateCategory(category: AdminCategory): Observable; deleteCategory(id: string): Observable; restoreCategory(id: string): Observable; isSlugTaken(slug: string, excludingId: string | null): Observable; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/backoffice/categories?search=&visibility=&includeDeleted=` | — | `AdminCategory[]` | | GET | `/backoffice/categories/:id` | — | `AdminCategory \| 404` | | POST | `/backoffice/categories` | `AdminCategory` | `201 AdminCategory` | | PUT | `/backoffice/categories/:id` | `AdminCategory` | `200 AdminCategory` | | DELETE | `/backoffice/categories/:id` | — | `204` (soft delete — sets `deletedAt`) | | POST | `/backoffice/categories/:id/restore` | — | `200 AdminCategory` (clears `deletedAt`) | | GET | `/backoffice/categories/slug-taken?slug=&excludingId=` | — | `{taken: boolean}` | | PATCH | `/backoffice/categories/reorder` | `{id, order}[]` | `204` — bulk reorder endpoint, not in the gateway interface today but needed since `AdminCategory.order` exists and the editor drag-reorders | DTO: see §7.1. ### 6.10 Backoffice — Products (PLANNED) Interface: `AdminProductsGateway` (`src/app/features/admin/products/services/admin-products-gateway.interface.ts`). Today: `AdminProductsLocalGateway`, in-memory. ```ts interface AdminProductsGateway { loadProducts(filters: AdminProductListFilters): Observable; loadProduct(id: string): Observable; loadCategories(): Observable; createProduct(product: AdminProduct): Observable; updateProduct(product: AdminProduct): Observable; deleteProduct(id: string): Observable; duplicateProduct(id: string): Observable; archiveProduct(id: string): Observable; restoreProduct(id: string): Observable; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/backoffice/products?search=&categoryId=&visibility=&stock=&includeArchived=&sort=&page=&pageSize=` | — | `AdminProductsListResult` (`{items, total, page, pageSize}`) | | GET | `/backoffice/products/:id` | — | `AdminProduct \| 404` | | GET | `/backoffice/products/categories` | — | `AdminProductCategoryOption[]` (`{id, title}`) — **still wired to its own seed today, not `AdminCategoriesGateway`; unify when both backends exist.** | | POST | `/backoffice/products` | `AdminProduct` | `201 AdminProduct` | | PUT | `/backoffice/products/:id` | `AdminProduct` | `200 AdminProduct` | | DELETE | `/backoffice/products/:id` | — | `204` (hard delete) | | POST | `/backoffice/products/:id/duplicate` | — | `201 AdminProduct` | | POST | `/backoffice/products/:id/archive` | — | `204` (soft archive) | | POST | `/backoffice/products/:id/restore` | — | `200 AdminProduct` | | PATCH | `/backoffice/products/bulk` | `{ids: string[], visible?: boolean, delete?: boolean}` | `204` — matches `applyBulkVisibility`/`applyBulkDelete` semantics referenced in the facade | DTO: see §7.2, including the production-matching flat-variant shape (`AdminProductVariant`) — this is the highest-fidelity DTO in this document; read §7.2 carefully before implementing. ### 6.11 Backoffice — Orders (PLANNED) Interface: `AdminOrdersGateway`. Today: `AdminOrdersLocalGateway` fabricates 24 synthetic in-memory orders — **no real order data exists anywhere in this system today.** The dashboard's Orders/Revenue cards intentionally render `pending-backend` rather than reading from this mock — they are not wired to it on purpose (the mock is order-management UI scaffolding, not a metrics source). ```ts interface AdminOrdersGateway { loadOrders(filters: AdminOrderListFilters): Observable; loadOrder(id: string): Observable; updateStatus(id: string, status: AdminOrderStatus, note: string): Observable; requestRefund(id: string): Observable; addNote(id: string, note: string, internal: boolean): Observable; archiveOrder(id: string): Observable; restoreOrder(id: string): Observable; deleteOrder(id: string): Observable; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/backoffice/orders?search=&status=&page=&pageSize=` | — | `AdminOrdersListResult` | | GET | `/backoffice/orders/:id` | — | `AdminOrder \| 404` | | PATCH | `/backoffice/orders/:id/status` | `{status: AdminOrderStatus, note: string}` | `200 AdminOrder` — must enforce the order state machine (§8.1) | | POST | `/backoffice/orders/:id/refund` | — | `200 AdminOrder` (sets `payment.status = 'refund_requested'`) | | POST | `/backoffice/orders/:id/notes` | `{note: string, internal: boolean}` | `200 AdminOrder` | | POST | `/backoffice/orders/:id/archive` | — | `200 AdminOrder` | | POST | `/backoffice/orders/:id/restore` | — | `200 AdminOrder` | | DELETE | `/backoffice/orders/:id` | — | `204` | | GET | `/backoffice/orders/export.csv` | — | CSV — UI has a CSV-export action | | GET | `/backoffice/orders/:id/invoice` | — | printable invoice — UI has a print-invoice action | | GET | `/backoffice/dashboard/revenue-summary` | — | aggregation feeding the dashboard's currently-`pending-backend` Orders/Revenue cards | DTO: see §7.3, state machine: §8.1. ### 6.12 Backoffice — Transactions (PLANNED) Interface: `AdminTransactionsGateway`. Today: `AdminTransactionsLocalGateway` derives one synthetic transaction per seeded mock order — **no real payment/transaction data exists.** ```ts interface AdminTransactionsGateway { loadTransactions(filters: AdminTransactionListFilters): Observable; retryFailed(id: string): Observable; setFraudFlag(id: string, flagged: boolean): Observable; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/backoffice/transactions?search=&status=&type=&page=&pageSize=` | — | `AdminTransactionsListResult` | | POST | `/backoffice/transactions/:id/retry` | — | `200 AdminTransaction` — semantics depend on whatever the real payment provider supports for retry | | PATCH | `/backoffice/transactions/:id/fraud-flag` | `{flagged: boolean}` | `200 AdminTransaction` | | GET | `/backoffice/transactions/export.csv` | — | CSV export | | GET | `/backoffice/transactions/:id/audit` | — | `AdminTransactionAuditEntry[]` | Needed: a real payments/transactions domain (card, QR, cash-on-delivery), linked to orders, with fraud-flag persistence. DTO: §7.4. ### 6.13 Backoffice — Users, roles, invitations (PLANNED) Interface: `AdminUsersGateway`. Today: `AdminUsersLocalGateway`, fully synthetic. **Passwordless login itself is real** (§2.4) — only the roles/permissions/invitations/multi-session-listing layer on top is mocked. ```ts interface AdminUsersGateway { loadUsers(): Observable; loadRoles(): Observable; loadInvitations(): Observable; loadSessions(userId: string): Observable; loadAudit(userId: string): Observable; setUserRole(userId: string, roleId: string): Observable; setUserStatus(userId: string, status: AdminUserStatus): Observable; inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable; revokeInvitation(id: string): Observable; revokeSession(sessionId: string): Observable; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/backoffice/users` | — | `AdminUser[]` | | GET | `/backoffice/roles` | — | `AdminRole[]` | | GET | `/backoffice/invitations` | — | `AdminInvitation[]` | | GET | `/backoffice/users/:id/sessions` | — | `AdminSession[]` — **must reflect real multi-device sessions**, unlike today's `AdminAuthService` which only knows the current browser's session | | GET | `/backoffice/users/:id/audit` | — | `AdminUserAuditEntry[]` | | PATCH | `/backoffice/users/:id/role` | `{roleId: string}` | `200 AdminUser` | | PATCH | `/backoffice/users/:id/status` | `{status: AdminUserStatus}` | `200 AdminUser` | | POST | `/backoffice/invitations` | `{email, roleId, scope}` | `201 AdminInvitation` — must trigger a real invitation email | | DELETE | `/backoffice/invitations/:id` | — | `204` | | DELETE | `/backoffice/sessions/:id` | — | `204` — revoke a specific device session | Ties directly to the §2.5 admin-authorization gap — role assignment here is meaningless until the auth layer actually enforces roles. DTO: §7.5–§7.6. ### 6.14 Backoffice — Moderation (reviews & reports) (PLANNED) Interface: `AdminModerationGateway`. No `*LocalGateway` file distinct from the interface was found under a separate name in this pass beyond `admin-moderation-local.gateway.ts` — same pattern as everywhere else (mock today, swappable). ```ts interface AdminModerationGateway { loadReviews(filters: AdminReviewListFilters): Observable; loadReview(id: string): Observable; setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable; setReviewVisible(id: string, visible: boolean): Observable; setReviewPinned(id: string, pinned: boolean): Observable; setReviewFeatured(id: string, featured: boolean): Observable; addModeratorNote(id: string, note: string): Observable; deleteReview(id: string): Observable; loadReports(): Observable; setReportStatus(id: string, status: AdminReportStatus): Observable; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/backoffice/reviews?search=&status=&rating=&page=&pageSize=` | — | `AdminReviewsListResult` | | GET | `/backoffice/reviews/:id` | — | `AdminReview \| 404` | | PATCH | `/backoffice/reviews/:id/status` | `{status: AdminReviewStatus, note: string}` | `200 AdminReview` (state machine §8.4) | | PATCH | `/backoffice/reviews/:id/visible` | `{visible: boolean}` | `200 AdminReview` | | PATCH | `/backoffice/reviews/:id/pinned` | `{pinned: boolean}` | `200 AdminReview` | | PATCH | `/backoffice/reviews/:id/featured` | `{featured: boolean}` | `200 AdminReview` | | POST | `/backoffice/reviews/:id/notes` | `{note: string}` | `200 AdminReview` | | DELETE | `/backoffice/reviews/:id` | — | `204` | | GET | `/backoffice/reports` | — | `AdminReport[]` | | PATCH | `/backoffice/reports/:id/status` | `{status: AdminReportStatus}` | `200 AdminReport` | DTO: §7.7. ### 6.15 Backoffice — Dashboard metrics & recent activity (PLANNED) Interface: `AdminDashboardMetricsGateway`. Today: `AdminDashboardMetricsLocalGateway` composes `BackofficeDataService.loadCategories()/loadProducts()` client-side into counts; everything else on the dashboard (marketplace status, theme, languages, last publish/save, bootstrap version, active layout, enabled widgets, system health) is derived from `ProjectEditorFacade` state, not a metrics endpoint. Recent Activity is `AdminDashboardHistoryService`, `localStorage`-backed, scoped per tenant (`adminDashboard.activityHistory.v1`) — will never show another editor's activity. ```ts interface AdminDashboardMetricsGateway { loadMetrics(): Observable; // today only { categoriesCount, productsCount } } ``` | Method | Endpoint | Response | |---|---|---| | GET | `/backoffice/dashboard/summary` | Real-time counts and trend deltas — richer than today's `{categoriesCount, productsCount}` | | GET | `/backoffice/dashboard/activity` | Real audit-log-backed activity feed, readable by multiple concurrent admin sessions (unlike today's per-browser localStorage) | DTO: §7.8. ### 6.16 Backoffice — Monitoring (PLANNED except Health) Interface: `AdminMonitoringGateway`. Health section reads real data (`AdminDashboardFacade.healthChecks`) already — unaffected. Everything else (audit/security/login/failed-login/API/error/warning event feed, queue depths, webhook deliveries) is synthetic — **no logging, queue, or webhook infrastructure exists anywhere in this system today.** ```ts interface AdminMonitoringGateway { loadEvents(filters: AdminMonitoringEventFilters): Observable; loadQueues(): Observable; loadWebhooks(): Observable; } ``` | Method | Endpoint | Response | |---|---|---| | GET | `/backoffice/monitoring/events?category=&search=` | `AdminMonitoringEvent[]` — real structured logging with a query API by category/level/actor/time-range | | GET | `/backoffice/monitoring/queues` | `AdminQueue[]` — real queue introspection, once a job runner exists | | GET | `/backoffice/monitoring/webhooks` | `AdminWebhookDelivery[]` — real webhook delivery tracking, once webhooks exist as a feature at all (§15) | DTO: §7.9. ### 6.17 Backoffice — Analytics (mostly FUTURE — no data source) `features/admin/analytics/` computes real revenue/orders/top-products aggregations from mock order data (§6.11), but visitor traffic, conversion funnels, and heatmaps have **zero data source anywhere in this system** — no analytics/tracking pipeline, no event collection. The page renders `pending-backend` badges rather than fabricated numbers; no gateway method exists for this yet, unlike every other mocked domain. | Method | Endpoint | Status | |---|---|---| | GET | `/backoffice/analytics/summary` | PLANNED — `AdminAnalyticsSummary`, revenue/orders/customers real once orders are real | | GET | `/backoffice/analytics/traffic` | FUTURE — needs an actual tracking pipeline | | GET | `/backoffice/analytics/funnels` | FUTURE | | GET | `/backoffice/analytics/heatmaps` | FUTURE | DTO: §7.10. ### 6.18 Media (PLANNED — ADR-0002) Domain model and contract are already spec'd in `docs/context/adrs/ADR-0002-media-manager-contract.md`; reproduced here as the canonical copy. `MediaRepository` (`src/app/core/media/media-repository.ts`) abstract class, two implementations selected via DI token: `MockMediaRepository` (IndexedDB — not localStorage, binary blobs need it) and `HttpMediaRepository` (to be built). ```ts abstract class MediaRepository { abstract list(params?: MediaListParams): Promise; abstract upload(file: File, options?: MediaUploadOptions): Promise; abstract remove(id: string): Promise; abstract update(id: string, patch: Partial>): Promise; abstract listFolders(): Promise; } ``` | Method | Endpoint | Body | Response | |---|---|---|---| | GET | `/media?page=&pageSize=&search=&folder=&tag=&kind=&sort=` | — | `MediaListResult` (`{items: MediaAsset[], total: number}`) | | POST | `/media/upload` | multipart, `+ folder?, tags?[]` | `201 MediaAsset` | | DELETE | `/media/:id` | — | `204` | | PATCH | `/media/:id` | `{altText?, tags?, folder?, caption?, description?, decorative?}` | `200 MediaAsset` | | GET | `/media/folders` | — | `string[]` | **Media never enters the Bootstrap model** — like products/orders/users, media assets are runtime admin data, not tenant configuration (ADR-0001). DTO: §7.11. ### 6.19 Sitemap (FUTURE — static baseline only today) `public/sitemap.xml` currently lists only the statically-known top-level routes (home/catalog/search/wishlist/compare) for the default locale, referenced from `public/robots.txt`. This platform is multi-tenant and config-driven — supported locales, categories, products, static pages are all resolved at runtime per tenant, not enumerable from the frontend at build time. A real per-tenant sitemap covering `/:lang/product/:id`, `/:lang/catalog/:categoryId`, `/:lang/:staticPath` needs a build-time or server-side job reading the same per-tenant data source (categories/products/static pages) and regenerating/serving this file dynamically — not something the SPA can produce correctly on its own. --- ## 7. DTOs Every interface below is lifted directly from its cited source file — field lists, optionality, and nullability are exact, not paraphrased. ### 7.1 Categories — `src/app/features/admin/categories/models/admin-category.model.ts` ```ts type AdminCategoryStatus = 'draft' | 'published'; interface AdminCategoryTranslation { title?: string; description?: string; seoTitle?: string; seoDescription?: string; } interface AdminCategorySeo { metaTitle: string; metaDescription: string; keywords: string; } interface AdminCategoryAttribute { key: string; value: string; } interface AdminCategory { id: string; parentId: string | null; // hierarchy title: string; slug: string; description: string; icon: string; imageUrl: string; imageAlt: string; order: number; visible: boolean; status: AdminCategoryStatus; // draft | published — generated field, editor-set itemsCount: number; // generated field, backend-computed translations: Record; seo: AdminCategorySeo; attributes: AdminCategoryAttribute[]; deletedAt: string | null; // generated, soft-delete createdAt: string; // generated updatedAt: string; // generated } interface AdminCategoryListFilters { search: string; visibility: 'all'|'visible'|'hidden'; includeDeleted: boolean; } ``` Note vs. the storefront-facing `CategoryDto`/`CategoryCardConfig` (`src/app/features/admin/**` seed source): those lack `parentId`, `slug`, `icon`, `imageUrl`, `status`, `deletedAt`, `seo`, `translations` — the admin model is a superset. Reconciling the two into one backend category table is a real design decision for the backend team; this document only certifies what the frontend needs, not how storage should be normalized. ### 7.2 Products — `src/app/features/admin/products/models/admin-product.model.ts` ```ts type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock'; type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated'; interface AdminProductMedia { images: string[]; gallery: string[]; videos: string[]; } interface AdminProductSpecification { key: string; value: string; } /** * Matches the production variant shape: a flat list of attribute-value * combinations, each priced per currency, e.g. * {color:'0x8B4513', size:'S', price:62560, currency:'RUB', remaining:100}. * The admin UI groups rows sharing the same attribute combo into one * AdminProductVariant with multiple prices, then flattens back on save. */ interface AdminProductVariantPrice { currency: string; price: number; } interface AdminProductVariant { id: string; attributes: Record; // e.g. { color: '0x8B4513', size: 'S' } sku: string; image: string; remaining: number; prices: AdminProductVariantPrice[]; } interface AdminProductVariantAttributeDef { key: string; // lowercase, backend field name (e.g. 'color', 'size') label: string; isColor: boolean; values: string[]; } interface AdminProductAttribute { key: string; value: string; } interface AdminProductTranslation { name?: string; shortDescription?: string; htmlDescription?: string; seoTitle?: string; seoDescription?: string; } interface AdminProductSeo { metaTitle: string; metaDescription: string; keywords: string; } interface AdminProductReview { id: string; author: string; rating: number; text: string; } interface AdminProductQuestion { id: string; question: string; answer?: string; } interface AdminProduct { id: string; name: string; slug: string; sku: string; barcode: string; brand: string; categoryId: string; visible: boolean; archived: boolean; priority: number; media: AdminProductMedia; price: number; discount: number; currency: string; quantity: number; stockStatus: AdminProductStockStatus; availability: string; shortDescription: string; htmlDescription: string; specifications: AdminProductSpecification[]; attributes: AdminProductAttribute[]; variantAttributes: AdminProductVariantAttributeDef[]; variants: AdminProductVariant[]; relatedProductIds: string[]; translations: Record; seo: AdminProductSeo; featured: boolean; recommended: boolean; isNew: boolean; bestseller: boolean; badges: string[]; reviews: AdminProductReview[]; questions: AdminProductQuestion[]; createdAt: string; updatedAt: string; } interface AdminProductListFilters { search: string; categoryId: string | null; visibility: 'all'|'visible'|'hidden'; stock: 'all' | AdminProductStockStatus; includeArchived: boolean; sort: AdminProductSort; page: number; pageSize: number; } interface AdminProductsListResult { items: AdminProduct[]; total: number; page: number; pageSize: number; } interface AdminProductCategoryOption { id: string; title: string; } ``` **Validation notes for the backend:** `sku` must be unique per tenant (§9.4). `variants[].sku` should also be unique among a product's own variants at minimum. `price`/`currency` required at the top level even when `variants` carries its own per-combination `prices` — top-level price is the "from" price shown on listing cards. ### 7.3 Orders — `src/app/features/admin/orders/models/admin-order.model.ts` ```ts type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'; type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded'; interface AdminOrderCustomer { name: string; email: string; phone: string; } interface AdminOrderPayment { method: string; status: AdminOrderPaymentStatus; amount: number; currency: string; } interface AdminOrderShipping { address: string; method: string; trackingNumber: string; } interface AdminOrderItem { productId: string; name: string; quantity: number; price: number; } interface AdminOrderTimelineEntry { status: AdminOrderStatus; timestamp: string; note: string; } interface AdminOrder { id: string; orderNumber: string; status: AdminOrderStatus; customer: AdminOrderCustomer; payment: AdminOrderPayment; shipping: AdminOrderShipping; items: AdminOrderItem[]; total: number; currency: string; notes: string; internalNotes: string; timeline: AdminOrderTimelineEntry[]; // audit trail of every status change archived: boolean; createdAt: string; updatedAt: string; } interface AdminOrderListFilters { search: string; status: 'all'|AdminOrderStatus; page: number; pageSize: number; } interface AdminOrdersListResult { items: AdminOrder[]; total: number; page: number; pageSize: number; } ``` `AdminCustomer` (`src/app/features/admin/customers/models/admin-customer.model.ts`) is **not its own stored entity** — it's derived by grouping `AdminOrder` records by `customer.email`, since no dedicated customer gateway/backend exists: ```ts interface AdminCustomer { email: string; name: string; phone: string; orderCount: number; totalSpent: number; currency: string; firstOrderAt: string; lastOrderAt: string; addresses: string[]; orders: AdminOrder[]; } ``` If/when a real customer domain is built, this becomes a real GET endpoint rather than a client-side aggregation — every field here should map 1:1 to a real column/aggregate, never fabricated. ### 7.4 Transactions — `src/app/features/admin/transactions/models/admin-transaction.model.ts` ```ts type AdminTransactionType = 'payment' | 'refund' | 'qr_payment'; type AdminTransactionStatus = 'pending' | 'success' | 'failed' | 'retried'; interface AdminTransactionAuditEntry { action: string; actor: string; timestamp: string; } interface AdminTransaction { id: string; orderId: string; orderNumber: string; type: AdminTransactionType; method: string; status: AdminTransactionStatus; amount: number; currency: string; fraudFlag: boolean; audit: AdminTransactionAuditEntry[]; createdAt: string; updatedAt: string; } interface AdminTransactionListFilters { search: string; status: 'all'|AdminTransactionStatus; type: 'all'|AdminTransactionType; page: number; pageSize: number; } interface AdminTransactionsListResult { items: AdminTransaction[]; total: number; page: number; pageSize: number; } ``` ### 7.5 Users, roles, invitations, sessions — `src/app/features/admin/users/models/admin-user.model.ts` ```ts type AdminUserScope = 'marketplace' | 'office'; type AdminUserStatus = 'active' | 'invited' | 'suspended'; type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked'; interface AdminRole { id: string; name: string; permissions: string[]; builtIn: boolean; } interface AdminUser { id: string; name: string; telegramUsername: string; email: string; scope: AdminUserScope; roleId: string; status: AdminUserStatus; lastLoginAt: string | null; createdAt: string; } interface AdminInvitation { id: string; email: string; roleId: string; scope: AdminUserScope; status: AdminInvitationStatus; invitedAt: string; expiresAt: string; } interface AdminSession { id: string; userId: string; device: string; ip: string; lastActiveAt: string; current: boolean; } interface AdminUserAuditEntry { action: string; actor: string; timestamp: string; } ``` ### 7.6 Permissions (bootstrap-level skeleton) — `src/app/shared/models/config/permissions.model.ts` ```ts interface PermissionDefinition { key: string; description?: string; } interface RolePermissions { role: string; permissions: string[]; } interface PermissionsConfig { definitions: PermissionDefinition[]; roles: RolePermissions[]; } ``` ### 7.7 Moderation — reviews & reports ```ts // admin-review.model.ts type AdminReviewStatus = 'pending' | 'approved' | 'rejected' | 'spam'; interface AdminReviewTimelineEntry { action: string; actor: string; note: string; timestamp: string; } interface AdminReview { id: string; productId: string; productName: string; customerName: string; customerEmail: string; rating: number; text: string; photos: string[]; status: AdminReviewStatus; visible: boolean; pinned: boolean; featured: boolean; reportCount: number; moderatorNotes: string; timeline: AdminReviewTimelineEntry[]; createdAt: string; updatedAt: string; } interface AdminReviewListFilters { search: string; status: 'all'|AdminReviewStatus; rating: 'all'|number; page: number; pageSize: number; } interface AdminReviewsListResult { items: AdminReview[]; total: number; page: number; pageSize: number; } // admin-report.model.ts type AdminReportTargetType = 'product' | 'review' | 'customer' | 'category' | 'unknown'; type AdminReportStatus = 'open' | 'resolved' | 'dismissed'; interface AdminReport { id: string; targetType: AdminReportTargetType; targetId: string; targetLabel: string; reason: string; reporterEmail: string; status: AdminReportStatus; createdAt: string; } ``` ### 7.8 Dashboard — `src/app/features/admin/dashboard/models/admin-dashboard.model.ts` ```ts type AdminDashboardCardStatus = 'loading' | 'ready' | 'empty' | 'error' | 'pending-backend'; interface AdminDashboardCardState { status: AdminDashboardCardStatus; value: T | null; } interface AdminDashboardMetrics { categoriesCount: number; productsCount: number; } // today's real shape — expand per §6.15 type AdminDashboardHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown' | 'loading'; interface AdminDashboardHomeHealthCheck { code: string; labelKey: string; status: AdminDashboardHealthStatus; displayValue?: string | null; } interface AdminDashboardActivityEntry { id: string; type: 'draft-saved'|'published'; timestamp: number; } ``` `pending-backend` is a real, intentional card status — it means "this sprint has no data source for this metric yet," rendered honestly instead of a fabricated number. Any new metrics endpoint should let the frontend distinguish "zero" from "no data source" the same way (e.g. omit the field, or use `null`, rather than sending `0`). ### 7.9 Monitoring — `src/app/features/admin/monitoring/models/admin-monitoring.model.ts` ```ts type AdminMonitoringCategory = 'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning'; type AdminMonitoringLevel = 'info' | 'warning' | 'error'; type AdminQueueStatus = 'healthy' | 'degraded' | 'down'; type AdminWebhookStatus = 'delivered' | 'failed' | 'pending'; interface AdminMonitoringEvent { id: string; category: AdminMonitoringCategory; level: AdminMonitoringLevel; message: string; actor: string; timestamp: string; } interface AdminMonitoringEventFilters { category: 'all'|AdminMonitoringCategory; search: string; } interface AdminQueue { name: string; depth: number; status: AdminQueueStatus; } interface AdminWebhookDelivery { id: string; endpoint: string; event: string; status: AdminWebhookStatus; timestamp: string; } ``` Payment/QR types (`src/app/services/api.service.ts` lines 10–66, frozen — §2.8): ```ts interface QrCreateRequest { qrtype: 'QRDynamic'; amount: number; currency: 'RUB'; partnerqrID?: string; qrDescription?: string; Userid?: string; Reference?: string; RedirectUrl?: string; } interface QrCreateResponse { qrId?: string; qrID?: string; nspkID?: string; nspkId?: string; nspkurl?: string; orderID?: string; url?: string; bankUrl?: string; status?: string; qrStatus?: string; qrExpirationDate?: string; payload?: string; Payload?: string; qrUrl?: string; partnerqrID?: string|number; partnerID?: string|number; partnerId?: string|number; PartnerID?: string|number; } interface CartPaymentRequest { amount: number; currency: 'RUB'; siteuserID: string; siteorderID: string; redirectUrl: string; telegramUsername: string; paymentMethod: 'qr'|'card'; items: Array<{itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[]}>; } interface QrDynamicStatusResponse { additionalInfo: string; paymentPurpose: string; amount: number; code: string; createDate: string; currency: string; order: string; status: string; qrId: string; transactionDate: string; transactionId: number; qrExpirationDate: string; } ``` ### 7.10 Analytics — `src/app/features/admin/analytics/models/admin-analytics.model.ts` ```ts type AdminAnalyticsDateRange = 7 | 30 | 90; interface AdminAnalyticsSummary { revenueTotal: number; currency: string; ordersCount: number; avgOrderValue: number; productsCount: number; categoriesCount: number; customersCount: number; conversionRate: number | null; // null = unknown, no visitor/traffic tracking exists yet — never fabricated } interface AdminAnalyticsSeriesPoint { date: string; value: number; } interface AdminAnalyticsTopProduct { productId: string; name: string; quantity: number; revenue: number; } interface AdminLowStockProduct { productId: string; name: string; quantity: number; stockStatus: 'low_stock'|'out_of_stock'; } interface AdminRecentActivityEntry { id: string; labelKey: string; timestamp: number; } type AdminMarketplaceHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown'; interface AdminMarketplaceHealthCheck { code: string; labelKey: string; status: AdminMarketplaceHealthStatus; displayValue?: string | null; actionRoute?: string[]; } interface AdminProductAnalyticsRow { productId: string; name: string; value: number; } interface AdminProductAnalytics { topSelling: AdminAnalyticsTopProduct[]; mostReviewed: AdminProductAnalyticsRow[]; worstRated: AdminProductAnalyticsRow[]; hiddenCount: number; archivedCount: number; } interface AdminCustomerAnalytics { newCustomers: number; returningCustomers: number; averageSpend: number; currency: string; retentionPercent: number | null; } type AdminRecommendationSeverity = 'info' | 'warning' | 'critical'; interface AdminRecommendationCard { id: string; labelKey: string; descriptionKey?: string; severity: AdminRecommendationSeverity; route: string[]; } ``` `conversionRate: number | null` and `retentionPercent: number | null` are the platform's explicit convention for "no data source yet" — `null` is a first-class, meaningful value here, not an oversight. Replicate this pattern for any new metric without a real source rather than defaulting to `0`. ### 7.11 Media — `src/app/core/media/models/media-asset.model.ts` ```ts type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other'; interface MediaAsset { id: string; url: string; thumbnailUrl?: string; filename: string; mimeType: string; size: number; width?: number; height?: number; altText?: Record; // translations.{lang} rule caption?: string; description?: string; decorative?: boolean; // suppresses missing-alt-text warning tags?: string[]; folder?: string; createdAt: string; } type MediaSort = 'recent' | 'name' | 'size'; interface MediaListParams { page?: number; pageSize?: number; search?: string; folder?: string; tag?: string; kind?: MediaAssetKind; sort?: MediaSort; } interface MediaUploadOptions { folder?: string; tags?: string[]; } interface MediaListResult { items: MediaAsset[]; total: number; } ``` ### 7.12 Bootstrap DTOs — see §4.3–§4.6 for the complete `BootstrapConfig` tree (not repeated here to avoid duplication). --- ## 8. State machines ### 8.1 Orders — `AdminOrderStatus` ``` pending → processing → shipped → delivered pending → cancelled processing → cancelled shipped → delivered delivered → refunded (via payment.status refund_requested → refunded, not a direct status jump) * → refunded (only through the refund request flow, never a direct status write) ``` Invalid transitions to reject server-side: `delivered → pending`, `cancelled → *` (terminal), `refunded → *` (terminal), skipping `shipped` to jump straight `processing → delivered` is allowed by the model (no `shipped` requirement enforced client-side) but should be a deliberate backend policy decision, not silently permitted. Every transition must append an `AdminOrderTimelineEntry {status, timestamp, note}` — the timeline is the audit trail, never overwritten. ### 8.2 Payment status — `AdminOrderPaymentStatus` ``` unpaid → paid paid → refund_requested refund_requested → refunded refund_requested → paid (refund rejected, reverts) ``` ### 8.3 Products — implicit via `visible`/`archived` booleans, not an enum ``` (new) → visible: true, archived: false visible: true ⇄ visible: false (toggle, reversible, any time) archived: false → archived: true (via archiveProduct — soft archive) archived: true → archived: false (via restoreProduct) archived: true → (hard delete) (via deleteProduct — irreversible) ``` `stockStatus` (`in_stock|low_stock|out_of_stock`) is derived from `quantity`, not independently settable — treat it as backend-computed, never accept it as client input on create/update. ### 8.4 Reviews — `AdminReviewStatus` ``` pending → approved pending → rejected pending → spam approved ⇄ rejected (moderator can reverse a decision) * → spam (any state can be flagged spam) ``` Every status change should append an `AdminReviewTimelineEntry`. `visible`, `pinned`, `featured` are independent booleans layered on top of `status` — a `rejected` review should probably force `visible: false` server-side regardless of what the client sends (defense in depth), even though nothing in the current frontend enforces that coupling. ### 8.5 Reports — `AdminReportStatus` ``` open → resolved open → dismissed resolved ⇄ dismissed (reversible, e.g. re-opened as a mistake) ``` ### 8.6 Static pages — `status: 'draft' | 'published'` (per-page, independent of whole-bootstrap draft/publish) ``` draft → published published → draft (unpublish) ``` Independent from the category/product `draft|published` pattern — no shared enum type, don't unify speculatively. ### 8.7 Sessions — `AuthStatus` / `AdminAuthStatus` ``` unknown → checking → authenticated unknown → checking → unauthenticated authenticated → unauthenticated (logout, or expiry check fails) ``` ### 8.8 Users — `AdminUserStatus` ``` invited → active (invitation accepted) active ⇄ suspended invited → (invitation revoked, never becomes a user) ``` ### 8.9 Invitations — `AdminInvitationStatus` ``` pending → accepted pending → expired (time-based, backend job) pending → revoked (admin action) ``` --- ## 9. Validation ### 9.1 Backend vs. frontend validation — the client validator is not a trust boundary `ProjectValidator` (`src/app/features/project-editor/services/project-validator.service.ts`) runs entirely client-side and blocks the Publish button, but **a malicious or stale client can bypass all of it.** `POST /builder/bootstrap/publish` must re-run equivalent checks server-side. The exact issue shape to mirror: ```ts interface ProjectValidationIssue { code: string; // stable machine code message: string; // i18n key, not a rendered string section?: string; // editor section for UI badges fieldKey?: string; // schema field key for inline errors severity: 'error' | 'warning'; // error blocks publishing; warning is advisory } ``` Checks the client runs today (replicate server-side, at minimum the `error`-severity ones): | Code | Severity | Rule | |---|---|---| | `missing-logo` | error | `branding.logoUrl` must be non-empty | | `no-languages` | error | `localization.supportedLocales` must be non-empty | | `default-locale-not-supported` | error | `localization.defaultLocale` must be in `supportedLocales` | | `invalid-url` | error | `tenant.websiteBaseUrl` must be `http(s)://...` if present | | `duplicate-slugs` | error | static-page `slug` (or `route` fallback) must be unique | | `duplicate-routes` | warning | `pages[].route.path` must be unique; static-page routes must not collide with page routes | | `empty-homepage` | error | the `home` page must have ≥1 section | | `missing-widget` | error | every homepage widget must have a non-empty `type` | | `invalid-widget-config` | error | every widget on every page needs `id`, `type`, `version`, and an object `props` | | `duplicate-nav-links` | error | `navigation.header` items must be unique by `(label, route)` | | `invalid-colors` | error | every `theme.palette` value must be a valid hex color | | `invalid-css` | warning | `