1745 lines
121 KiB
Markdown
1745 lines
121 KiB
Markdown
# 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 <token>` — 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: <same guid>`. 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-<timestamp>`) 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 <adminToken>` |
|
||
| 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 <adminToken>` 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<string,string>`, `shadows: Record<string,string>`, `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<string, ApiEndpointConfig>, builder: Record<string, ApiEndpointConfig>, backoffice: Record<string, ApiEndpointConfig> }` 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<string,string>`, `dictionaries: LocalizationDictionaryRef[]` (`{locale, dictionaryUrl, version}`). |
|
||
| `seo` | `seo.model.ts` | `default: SeoPageConfig`, `byPageKey: Record<string, SeoPageConfig>`. `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<string,unknown> }`. 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<string, string>;
|
||
permissions?: { requireAuthenticated?: boolean; roles?: string[]; permissions?: string[] };
|
||
props: Record<string, unknown>;
|
||
actions?: Record<string, { type: 'navigate'|'open'|'submit'|'custom'; target?: string; payload?: Record<string, unknown> }>;
|
||
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<string, StaticPageConfig> | LegacyStaticPageConfig[]; // both accepted
|
||
|
||
interface StaticPageConfig {
|
||
id: string;
|
||
slug: string;
|
||
title: string | Record<string, string>;
|
||
showInFooter?: boolean; showInHeader?: boolean; showInSitemap?: boolean;
|
||
icon?: string; order?: number;
|
||
visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
|
||
requiresAuthentication?: boolean;
|
||
footerGroup?: string;
|
||
translations?: Record<string, { title?: string; html?: string; seo?: {...} }>;
|
||
html?: string | Record<string, string>;
|
||
seo?: StaticPageSeoConfig;
|
||
visible?: boolean;
|
||
route?: string;
|
||
content?: Record<string, string>;
|
||
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": "<h1>About Us</h1>" } } }
|
||
}
|
||
}
|
||
```
|
||
|
||
### 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<void>` (→ `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<string, T>` keyed by locale code (`ru`/`en`/`hy` in this deployment, but the model itself is not hardcoded to those three): `AdminProduct.translations: Record<string, AdminProductTranslation>`, `AdminCategory.translations: Record<string, AdminCategoryTranslation>`, `StaticPageConfig.translations: Record<string, StaticPageTranslationConfig>`, `NavigationItemConfig.label: string | Record<string, string>`, `FooterConfig.copyrightText: string | Record<string, string>`. 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<AdminCategory[]>;
|
||
loadCategory(id: string): Observable<AdminCategory | null>;
|
||
createCategory(category: AdminCategory): Observable<AdminCategory>;
|
||
updateCategory(category: AdminCategory): Observable<AdminCategory>;
|
||
deleteCategory(id: string): Observable<void>;
|
||
restoreCategory(id: string): Observable<AdminCategory | null>;
|
||
isSlugTaken(slug: string, excludingId: string | null): Observable<boolean>;
|
||
}
|
||
```
|
||
|
||
| 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<AdminProductsListResult>;
|
||
loadProduct(id: string): Observable<AdminProduct | null>;
|
||
loadCategories(): Observable<AdminProductCategoryOption[]>;
|
||
createProduct(product: AdminProduct): Observable<AdminProduct>;
|
||
updateProduct(product: AdminProduct): Observable<AdminProduct>;
|
||
deleteProduct(id: string): Observable<void>;
|
||
duplicateProduct(id: string): Observable<AdminProduct | null>;
|
||
archiveProduct(id: string): Observable<void>;
|
||
restoreProduct(id: string): Observable<AdminProduct | null>;
|
||
}
|
||
```
|
||
|
||
| 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<AdminOrdersListResult>;
|
||
loadOrder(id: string): Observable<AdminOrder | null>;
|
||
updateStatus(id: string, status: AdminOrderStatus, note: string): Observable<AdminOrder | null>;
|
||
requestRefund(id: string): Observable<AdminOrder | null>;
|
||
addNote(id: string, note: string, internal: boolean): Observable<AdminOrder | null>;
|
||
archiveOrder(id: string): Observable<AdminOrder | null>;
|
||
restoreOrder(id: string): Observable<AdminOrder | null>;
|
||
deleteOrder(id: string): Observable<void>;
|
||
}
|
||
```
|
||
|
||
| 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<AdminTransactionsListResult>;
|
||
retryFailed(id: string): Observable<AdminTransaction | null>;
|
||
setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null>;
|
||
}
|
||
```
|
||
|
||
| 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<AdminUser[]>;
|
||
loadRoles(): Observable<AdminRole[]>;
|
||
loadInvitations(): Observable<AdminInvitation[]>;
|
||
loadSessions(userId: string): Observable<AdminSession[]>;
|
||
loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;
|
||
setUserRole(userId: string, roleId: string): Observable<AdminUser | null>;
|
||
setUserStatus(userId: string, status: AdminUserStatus): Observable<AdminUser | null>;
|
||
inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable<AdminInvitation>;
|
||
revokeInvitation(id: string): Observable<void>;
|
||
revokeSession(sessionId: string): Observable<void>;
|
||
}
|
||
```
|
||
|
||
| 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<AdminReviewsListResult>;
|
||
loadReview(id: string): Observable<AdminReview | null>;
|
||
setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null>;
|
||
setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null>;
|
||
setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null>;
|
||
setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null>;
|
||
addModeratorNote(id: string, note: string): Observable<AdminReview | null>;
|
||
deleteReview(id: string): Observable<void>;
|
||
loadReports(): Observable<AdminReport[]>;
|
||
setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null>;
|
||
}
|
||
```
|
||
|
||
| 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<AdminDashboardMetrics>; // 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<AdminMonitoringEvent[]>;
|
||
loadQueues(): Observable<AdminQueue[]>;
|
||
loadWebhooks(): Observable<AdminWebhookDelivery[]>;
|
||
}
|
||
```
|
||
|
||
| 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<MediaListResult>;
|
||
abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
|
||
abstract remove(id: string): Promise<void>;
|
||
abstract update(id: string, patch: Partial<Pick<MediaAsset,'altText'|'tags'|'folder'|'caption'|'description'|'decorative'>>): Promise<MediaAsset>;
|
||
abstract listFolders(): Promise<string[]>;
|
||
}
|
||
```
|
||
|
||
| 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<string, AdminCategoryTranslation>;
|
||
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<string, string>; // 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<string, AdminProductTranslation>;
|
||
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<T> { 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<string, string>; // 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 | `<style>` blocks inside static-page HTML must be syntactically valid CSS |
|
||
| `missing-translations` | error | every non-default supported locale needs header-nav and static-page translations |
|
||
| `invalid-layouts` | error | `layout.type` and every `section.layout.strategy` must be from the known enum set |
|
||
| `invalid-contact-email` | error | `company.contacts.email` must be a valid email if present |
|
||
| `invalid-social-link-url` | warning | `footer.socialLinks[].url` must be `http(s)://...` if present |
|
||
| `incomplete-payment-icon` | warning | `footer.paymentIcons[]` rows need both `src` and `alt` or neither |
|
||
|
||
### 9.2 Duplicate validation (client + server, deliberately)
|
||
|
||
Slug/route uniqueness, hex-color format, and URL format are validated **both** client-side (fast UX feedback) and must be re-validated server-side (real trust boundary) — this is intentional duplication, not a bug to eliminate on either side.
|
||
|
||
### 9.3 Required vs. generated fields
|
||
|
||
Generated (backend-set, never accept as client input on create): `id`, `createdAt`, `updatedAt`, `deletedAt`, `itemsCount` (categories), `stockStatus` (products, derived from `quantity`), order `timeline[]` entries, transaction `audit[]` entries, review `timeline[]` entries.
|
||
|
||
Required on create (reject with 422 if missing): category `title`/`slug`; product `name`/`sku`/`categoryId`/`price`/`currency`; order items must reference valid `productId`s.
|
||
|
||
### 9.4 Slug / SKU / barcode uniqueness
|
||
|
||
- Category `slug` — unique per tenant. `isSlugTaken(slug, excludingId)` gateway method exists precisely for this — mirror it as `GET /backoffice/categories/slug-taken`.
|
||
- Product `sku` — unique per tenant (§7.2 note).
|
||
- Product `barcode` — present in the model (Sprint 21 addition) but no explicit uniqueness-check gateway method exists yet; recommend uniqueness per tenant anyway unless barcodes are intentionally shared across variants.
|
||
|
||
### 9.5 Media validation
|
||
|
||
File size/type constraints and malware scanning before publish are **recommended, not yet specified precisely** — no size/type limit constants exist in the current frontend code (`docs/architecture/backend/Backend-Platform-API-Spec.md`'s draft §10 also just says "File size/type constraints" without numbers). Pick sane limits (e.g. 10MB images, common web-safe MIME types) and document them in the response envelope so the frontend can render a real client-side pre-check.
|
||
|
||
### 9.6 Language validation
|
||
|
||
Every translatable field must expose the full set of `localization.supportedLocales` — a locale missing from `translations` on a required-translation object (nav labels, static-page content per §9.1's `missing-translations` check) should be a validation error, not silently defaulting to the base language, so authors get an explicit signal.
|
||
|
||
---
|
||
|
||
## 10. Media
|
||
|
||
Covered in full in §6.18 (endpoints) and §7.11 (DTO). Summary of concerns not already stated:
|
||
|
||
- **Upload** is multipart `POST /media/upload`; response is a full `MediaAsset` including server-computed `width`/`height` for images and a `thumbnailUrl`.
|
||
- **Delete** is a hard delete (`DELETE /media/:id` → 204) — no soft-delete/restore concept exists for media today; if that's needed, it's a net-new decision, not something to infer from the current contract.
|
||
- **Replace** — no dedicated "replace" endpoint; the frontend pattern is delete + re-upload, or `PATCH` for metadata-only changes (alt text, tags, folder, caption, description, decorative flag) without touching the binary.
|
||
- **Metadata**: `altText` is a `Record<locale, string>` (translatable, ADR-0001 rule) — never a flat string. `decorative: boolean` intentionally suppresses missing-alt-text warnings for images that are genuinely decorative.
|
||
- **Compression/formats/limits/CDN/thumbnail generation** — none of this is specified in the frontend contract today; these are backend implementation choices as long as the `MediaAsset` response shape is honored (`url`, `thumbnailUrl?`, `mimeType`, `size`, `width?`, `height?`).
|
||
- **Future storage**: `MockMediaRepository` uses IndexedDB client-side purely as an interim store — this has zero bearing on backend storage choice (S3-compatible object storage, local disk, CDN-fronted, etc. are all compatible with the `MediaAsset.url` contract).
|
||
|
||
---
|
||
|
||
## 11. Errors
|
||
|
||
### 11.1 Standard error envelope
|
||
|
||
No single error shape is enforced by any HTTP client code today (the frontend mostly just surfaces `error.message` generically) — this is a **recommended standard**, consistent with `docs/architecture/backend/Backend-Platform-API-Spec.md`'s draft, extended to match the fuller field set the spec brief calls for:
|
||
|
||
```json
|
||
{
|
||
"code": "validation_error",
|
||
"title": "Validation failed",
|
||
"message": "One or more fields are invalid.",
|
||
"severity": "error",
|
||
"details": [
|
||
{ "field": "sku", "message": "SKU already exists for this tenant" }
|
||
],
|
||
"fieldErrors": {
|
||
"sku": ["SKU already exists for this tenant"]
|
||
},
|
||
"retryAfter": null,
|
||
"traceId": "trc-8f3a2c1e"
|
||
}
|
||
```
|
||
|
||
- `code` — stable machine-readable identifier (snake_case), for programmatic handling.
|
||
- `title` — short human summary.
|
||
- `message` — fuller human-readable explanation, safe to show a user.
|
||
- `severity` — `error | warning | info` (mirrors `ProjectValidationIssue.severity`, §9.1, for consistency between builder-validation issues and generic API errors).
|
||
- `details` — array form for multi-field validation failures (matches the draft spec's shape).
|
||
- `fieldErrors` — map form of the same information, keyed by field path, for forms that want O(1) per-field lookup instead of scanning `details`.
|
||
- `retryAfter` — seconds, populated on 429/503 responses; `null` otherwise.
|
||
- `traceId` — correlates a client-visible error to backend logs.
|
||
|
||
### 11.2 Common codes
|
||
|
||
`tenant_not_found`, `tenant_suspended`, `unauthorized`, `forbidden`, `validation_error`, `conflict`, `not_found`, `internal_error`, plus the reliability-mode codes below.
|
||
|
||
### 11.3 HTTP status meanings (repeated from §5.3 with error-body expectations)
|
||
|
||
| Status | When | Body |
|
||
|---|---|---|
|
||
| 400 | Malformed request (bad JSON, wrong content-type) | error envelope, `code: bad_request` |
|
||
| 401 | No/invalid session | error envelope, `code: unauthorized` |
|
||
| 403 | Valid session, insufficient permission | error envelope, `code: forbidden` |
|
||
| 404 | Resource/tenant not found | error envelope, `code: not_found` or `tenant_not_found` |
|
||
| 409 | Slug/SKU conflict, concurrent-edit conflict | error envelope, `code: conflict`, `details` naming the conflicting field |
|
||
| 422 | Validation failure | error envelope, `code: validation_error`, `details`/`fieldErrors` populated |
|
||
| 429 | Rate limited | error envelope, `code: rate_limited`, `retryAfter` populated |
|
||
| 500 | Unhandled error | error envelope, `code: internal_error` — never leak stack traces or internals in `message` |
|
||
| 503 | Service degraded/down | error envelope, `code` from the maintenance-mode set below, `retryAfter` if known |
|
||
|
||
### 11.4 Degraded-service modes
|
||
|
||
Not implemented today but recommended so the frontend can render an honest state instead of a generic failure:
|
||
|
||
- `maintenance_mode` — planned downtime.
|
||
- `construction_mode` — tenant not yet fully configured/launched.
|
||
- `read_only_mode` — writes disabled, reads still served.
|
||
- `feature_disabled` — a specific feature flag is off; distinct from a generic 403.
|
||
- `backend_unavailable` / `database_unavailable` / `queue_unavailable` — dependency-specific outage codes so ops/on-call can triage faster than a generic 500.
|
||
- **Graceful degradation:** where possible, prefer serving stale/cached data with a warning over a hard failure (e.g. bootstrap should be aggressively cached client + edge side specifically so a backend blip doesn't blank the entire storefront — see §13).
|
||
|
||
### 11.5 The one known reliability issue in production today
|
||
|
||
Intermittent `502`/`504 Bad Gateway` on page refresh and browser back-navigation in production. **Root cause is not in this repository.** `environment.production.ts` points the frontend at the backend API via **absolute URLs directly** (`apiUrl: 'https://api.dexarmarket.ru:445'`, `authApiUrl: 'https://users.vitanova.network:456'`), bypassing this repo's own `nginx.conf` entirely (that config only proxies `/api` for one specific tenant host, not `dexarmarket.ru`). The 502/504 originates from **that backend API's own reverse proxy** (ports 445/456, servers not part of this repository). Refresh and back-navigation both re-fire session-check and bootstrap-load calls on mount (`AdminAuthService.checkSession()`, `ConfigService.loadBootstrap()`, `TelegramSessionApiService`), which is the likely trigger if that backend's app server or reverse proxy is crashing, overloaded, or misconfigured on those specific endpoints. **This needs DevOps/backend investigation of upstream health, timeout settings, and concurrent-connection handling around session-check and bootstrap endpoints** — nothing in this frontend repository can fix it.
|
||
|
||
---
|
||
|
||
## 12. Localization
|
||
|
||
- `LocalizationConfig` (§4.4): `defaultLocale`, `supportedLocales[]`, `currencyByLocale: Record<string,string>`, `dictionaries: {locale, dictionaryUrl, version}[]`.
|
||
- **LocaleMap convention** (§5.11): every translatable field is `Record<locale, string>` (or a `translations[locale]` object for richer content) — never a flat hardcoded field. This applies to navigation labels, static-page title/html/seo, product/category translations, footer copyright text.
|
||
- **Fallback:** the client resolves a missing translation by falling back toward `defaultLocale` in most rendering paths (e.g. category/item `name` falls back to the first available `names[]` entry, preferring `ru` — see `ApiService.normalizeCategory`/`normalizeItem`, §6.1) — but this is a display-layer fallback for legacy/partial data, not a substitute for actually authoring translations. §9.1's `missing-translations` validation exists specifically to push authors toward complete translations rather than relying on fallback.
|
||
- **Default language:** `localization.defaultLocale` must always be a member of `localization.supportedLocales` — enforced client-side (`default-locale-not-supported`, §9.1) and must be enforced server-side too.
|
||
- **`Accept-Language` vs. `X-Language`:** the frontend does **not** send `Accept-Language`; it sends its own resolved `X-Language` header (`RU`/`EN`/`AM`, mapped from the user's in-app language selection, §1 step 3) on every API request. Backend should key localized responses off `X-Language`, not the browser's `Accept-Language`.
|
||
- **Changing language:** a pure frontend state change (`LanguageService.currentLanguage`) — no backend call is made purely to switch language; the next API request simply carries the new `X-Language` header.
|
||
- **Static pages:** localized via `translations[locale].{title,html,seo}` (§4.6) — SEO fields can also be locale-specific (`StaticPageTranslationConfig.seo`), overriding the page-level `seo` block per locale.
|
||
- **SEO:** `SeoConfig.default` + `SeoConfig.byPageKey` (§4.4) at the bootstrap level; per-static-page SEO nested under each page's `seo`/`translations[locale].seo` (§4.6). No separate SEO-only endpoint exists — SEO data travels inside bootstrap and static-page payloads.
|
||
|
||
---
|
||
|
||
## 13. Caching
|
||
|
||
| Layer | What | Mechanism today | Recommendation |
|
||
|---|---|---|---|
|
||
| Bootstrap | `GET /bootstrap` | `ConfigService` caches the whole response in-memory for the app session via `shareReplay(1)`; no HTTP-level caching | Add `ETag`/`Cache-Control: private, max-age=60` (or similar) — safe additive change, client doesn't send conditional headers today so this is a pure improvement, not a breaking change |
|
||
| Products/categories (storefront) | `GET /category`, `GET /category/:id`, `GET /items/:id` | `cacheInterceptor` (`src/app/interceptors/cache.interceptor.ts`) — in-memory `Map`, TTL from `CACHE_DURATION_MS` (category list) / `CATEGORY_CACHE_DURATION_MS` (category items + single item), keyed by exact request URL, cleaned up lazily on each access | Backend should still set its own `Cache-Control`/`ETag` for CDN/edge caching — the frontend's cache is per-tab, in-memory, and does not survive a reload |
|
||
| Images | media assets | none specified client-side | CDN-fronted with long `max-age` + content-hashed URLs recommended (not currently required by any frontend code) |
|
||
| Analytics | dashboard/analytics reads | none — always live | fine to cache short-TTL (seconds) server-side given these are aggregation-heavy |
|
||
| Browser cache | general | standard browser HTTP cache applies to anything the backend marks cacheable | — |
|
||
| Invalidation | — | today: none — the in-memory `cacheInterceptor` map is purely TTL-based, no explicit invalidation hook exists anywhere (e.g. a product update does not proactively bust the `GET /items/:id` cache entry) | if strong consistency after an admin write matters, either shorten `CATEGORY_CACHE_DURATION_MS` further or add an explicit cache-bust mechanism — not present today |
|
||
|
||
**Bootstrap caching is the most consequential** — since it's loaded once and held for the entire session, any admin publish (§6.7) will not be visible to already-open customer tabs until they reload. Consider a low-TTL `Cache-Control` on `GET /bootstrap` plus (future) a lightweight "config changed" push/poll signal if near-real-time propagation matters — no such mechanism exists in the frontend today.
|
||
|
||
---
|
||
|
||
## 14. Backend replacement pattern
|
||
|
||
This is the mechanical pattern **every** domain in this document follows, and the reason implementing a backend for any one domain is low-risk and additive:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Page["Page component"] --> Facade["Domain Facade"]
|
||
Facade --> Token["DI Token (e.g. ADMIN_PRODUCTS_GATEWAY)"]
|
||
Token -.bound today.-> Mock["*LocalGateway (mock/localStorage/IndexedDB)"]
|
||
Token -.rebind to.-> Api["*ApiGateway (real HTTP, implements same interface)"]
|
||
Api --> Backend["Backend REST API"]
|
||
```
|
||
|
||
1. A `*Gateway` TypeScript interface defines the domain's contract (e.g. `AdminProductsGateway`, `AdminCategoriesGateway`, `MediaRepository`).
|
||
2. A `*LocalGateway` (or, for media, `MockMediaRepository`) implements that interface today, bound via an Angular DI token/injection.
|
||
3. To go live: implement a `*ApiGateway` class against the **same interface** (same method signatures, same DTOs — §7), hitting the real endpoints documented in §6.
|
||
4. Rebind the DI token to the new class in the app's provider config (`app.config.ts` or the relevant feature's providers).
|
||
5. **Facades and page components do not change.** This is the whole point of the pattern (ADR-004's "configuration provider abstraction" generalizes to every domain, not just bootstrap).
|
||
|
||
Provider *selection* (mock vs. api) for bootstrap/backoffice/products/categories specifically goes through `RuntimeProviderStrategyService` (`src/app/core/providers/runtime-provider-strategy.service.ts`):
|
||
|
||
```ts
|
||
type RuntimeProviderMode = 'mock' | 'api' | 'remote-config';
|
||
|
||
class RuntimeProviderStrategyService {
|
||
getBootstrapProviderMode(): RuntimeProviderMode {
|
||
// 'mock' if environment.useMockData, OR (useMockBootstrapOnLocal && running on localhost)
|
||
// 'api' otherwise
|
||
}
|
||
getBackofficeProviderMode(): RuntimeProviderMode { /* 'mock' iff environment.useMockData, else 'api' */ }
|
||
getProductProviderMode(): RuntimeProviderMode { /* same */ }
|
||
getCategoryProviderMode(): RuntimeProviderMode { /* same */ }
|
||
}
|
||
```
|
||
|
||
Note the asymmetry: bootstrap can be mocked on localhost independent of `useMockData` (`useMockBootstrapOnLocal: true` by default) so local dev never needs a live bootstrap endpoint, while product/category/backoffice mocking is a single global `useMockData` switch. `'remote-config'` is a declared-but-unused third mode — reserved, not implemented anywhere today.
|
||
|
||
---
|
||
|
||
## 15. Future APIs
|
||
|
||
Reserved sections only — **do not implement speculative endpoints for these**; document intent, not a contract, until real requirements exist. No frontend code references any of the following today.
|
||
|
||
| Domain | Notes |
|
||
|---|---|
|
||
| Coupons | `featureFlags.coupons` boolean already exists in bootstrap (unused beyond the flag itself) |
|
||
| Discounts | `AdminProduct.discount: number` exists (§7.2) as a static per-product value; a real discount/promotion *engine* (rules, date ranges, stacking) does not exist |
|
||
| Promotions | no model anywhere |
|
||
| Notifications | `featureFlags.notifications` boolean exists; no delivery mechanism (email/push/in-app) specified |
|
||
| Marketplace Partners | not modeled — this platform is single-tenant-per-marketplace, not itself a marketplace-of-marketplaces |
|
||
| Inventory | `AdminProduct.quantity`/`stockStatus` exist per-product; no warehouse/location-level inventory model |
|
||
| Warehouses | not modeled |
|
||
| Delivery | `DeliveryOption` exists for storefront cart display (`src/app/models`, referenced by `ApiService`) — a full delivery/logistics domain (carriers, rates, tracking beyond `AdminOrderShipping.trackingNumber`) does not exist |
|
||
| Invoices | `featureFlags.invoices` boolean exists; `AdminOrdersGateway` UI has a "print invoice" action today but it's client-rendered from existing order data, not a backend invoice document |
|
||
| Refunds | `requestRefund()` exists (§6.11, §8.2) as a status transition; a full refund-processing domain (partial refunds, refund reasons, ledger) does not |
|
||
| Webhooks | `AdminWebhookDelivery` model exists for *displaying* delivery status (§7.9) but no webhook *registration/configuration* surface exists anywhere |
|
||
| Audit Logs | `AdminMonitoringEvent`/`AdminUserAuditEntry`/order `timeline[]`/review `timeline[]`/transaction `audit[]` are all narrow, per-domain audit trails today; a unified cross-domain audit log does not exist |
|
||
| Roles | `AdminRole`/`PermissionsConfig` exist as data shapes (§7.5–§7.6) but are not enforced anywhere server-side yet (§2.5, §3.3) |
|
||
| Permissions | same as Roles — modeled, not enforced |
|
||
| Feature Flags (management API) | `FeatureFlagsConfig`/`MarketplaceFeaturesConfig` are read via bootstrap only; no dedicated `GET/PUT /feature-flags` endpoint is called by any frontend code today (the draft spec in the old `Backend-Platform-API-Spec.md` proposed one — reasonable to build, just not yet consumed) |
|
||
|
||
---
|
||
|
||
## 16. Sequence diagrams
|
||
|
||
### 16.1 Authentication (customer or admin — identical backend flow, different client storage)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as User (browser)
|
||
participant FE as Frontend (AuthService / AdminAuthService)
|
||
participant TG as Telegram Bot
|
||
participant BE as Backend (users/sessions)
|
||
|
||
FE->>BE: POST /users/sessions {webSessionID}
|
||
BE-->>FE: {webSessionID, ...}
|
||
FE->>U: Render QR / deep link (t.me/{bot}?start={webSessionID})
|
||
U->>TG: Scan QR / tap deep link
|
||
TG->>BE: (out of band) associate Telegram identity with webSessionID
|
||
loop every 5s, up to 100x
|
||
FE->>BE: GET /users/sessions/:webSessionID
|
||
BE-->>FE: {active: false, ...}
|
||
end
|
||
TG->>BE: user completes bot interaction
|
||
FE->>BE: GET /users/sessions/:webSessionID
|
||
BE-->>FE: {active: true, user: {...}, expiresAt}
|
||
FE->>FE: activateSession() -> set cookie, start refresh timer
|
||
```
|
||
|
||
### 16.2 Bootstrap load
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant App as Angular App
|
||
participant CS as ConfigService
|
||
participant Prov as ApiBootstrapProvider / MockBootstrapProvider
|
||
participant BE as Backend
|
||
|
||
App->>CS: loadBootstrap()
|
||
alt already cached this session
|
||
CS-->>App: cached BootstrapConfig (shareReplay)
|
||
else not cached
|
||
CS->>Prov: loadBootstrap()
|
||
alt mock mode / localhost
|
||
Prov-->>CS: bootstrap.json (local asset)
|
||
else real
|
||
Prov->>BE: GET /bootstrap (Host header resolves tenant)
|
||
BE-->>Prov: BootstrapConfig JSON
|
||
end
|
||
CS-->>App: BootstrapConfig
|
||
App->>App: PlatformRuntimeService applies theme/branding/nav
|
||
end
|
||
```
|
||
|
||
### 16.3 Product editing (admin) — PLANNED, once `AdminProductsApiGateway` exists
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Admin as Admin user
|
||
participant Page as Product editor page
|
||
participant Facade as AdminProductsFacade
|
||
participant GW as AdminProductsApiGateway
|
||
participant BE as Backend
|
||
|
||
Admin->>Page: edit fields, save
|
||
Page->>Facade: updateProduct(product)
|
||
Facade->>GW: updateProduct(product)
|
||
GW->>BE: PUT /backoffice/products/:id
|
||
BE-->>GW: 200 AdminProduct (server-validated)
|
||
GW-->>Facade: AdminProduct
|
||
Facade-->>Page: updated state
|
||
```
|
||
|
||
### 16.4 Publishing (Project Editor) — PLANNED
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Editor as Tenant admin
|
||
participant PE as ProjectEditorFacade
|
||
participant Val as ProjectValidator (client)
|
||
participant BE as Backend (builder/bootstrap)
|
||
participant Store as ConfigService (storefront)
|
||
|
||
Editor->>PE: click Publish
|
||
PE->>Val: validate(bootstrap)
|
||
alt has error-severity issues
|
||
Val-->>PE: issues[]
|
||
PE-->>Editor: block publish, show issues
|
||
else clean
|
||
PE->>BE: POST /builder/bootstrap/publish {BootstrapConfig}
|
||
BE->>BE: server-side re-validation (mandatory, §9.1)
|
||
alt server rejects
|
||
BE-->>PE: 422 {details: [...]}
|
||
PE-->>Editor: show server-side issues
|
||
else accepted
|
||
BE-->>PE: 200
|
||
PE->>Store: reloadFromBootstrap() (in-memory preview)
|
||
Note over Store: next GET /bootstrap by any client now returns the new config
|
||
end
|
||
end
|
||
```
|
||
|
||
### 16.5 Checkout / cart (CURRENT, frozen)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as Shopper
|
||
participant FE as Frontend (CartService)
|
||
participant BE as Backend
|
||
|
||
U->>FE: add item to cart
|
||
FE->>BE: POST /websession/:sessionId [{itemID, quantity, ...}]
|
||
BE-->>FE: 200
|
||
U->>FE: initiate payment
|
||
FE->>BE: POST /cart (or {qrApiUrl}/qr) - CartPaymentRequest
|
||
BE-->>FE: QrCreateResponse {qrId/nspkurl/bankUrl/...}
|
||
FE->>U: render QR or redirect to bankUrl
|
||
loop poll
|
||
FE->>BE: GET {qrApiUrl}/qr/dynamic/:partnerId/:qrId
|
||
BE-->>FE: QrDynamicStatusResponse {status}
|
||
end
|
||
```
|
||
|
||
### 16.6 Media upload — PLANNED
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Admin as Admin user
|
||
participant Picker as MediaPickerComponent
|
||
participant Repo as HttpMediaRepository
|
||
participant BE as Backend
|
||
|
||
Admin->>Picker: select file
|
||
Picker->>Repo: upload(file, {folder, tags})
|
||
Repo->>BE: POST /media/upload (multipart)
|
||
BE->>BE: validate size/type, scan, compute width/height, generate thumbnail
|
||
BE-->>Repo: 201 MediaAsset
|
||
Repo-->>Picker: MediaAsset
|
||
Picker-->>Admin: asset selected / inserted
|
||
```
|
||
|
||
### 16.7 Review moderation — PLANNED
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Mod as Moderator
|
||
participant Page as Moderation page
|
||
participant GW as AdminModerationApiGateway
|
||
participant BE as Backend
|
||
|
||
Mod->>Page: set review status = approved, add note
|
||
Page->>GW: setReviewStatus(id, 'approved', note)
|
||
GW->>BE: PATCH /backoffice/reviews/:id/status {status, note}
|
||
BE->>BE: append AdminReviewTimelineEntry, enforce state machine (§8.4)
|
||
BE-->>GW: 200 AdminReview
|
||
GW-->>Page: updated review
|
||
```
|
||
|
||
### 16.8 Report resolution — PLANNED
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Mod as Moderator
|
||
participant Page as Moderation page (Reports tab)
|
||
participant GW as AdminModerationApiGateway
|
||
participant BE as Backend
|
||
|
||
Mod->>Page: mark report resolved
|
||
Page->>GW: setReportStatus(id, 'resolved')
|
||
GW->>BE: PATCH /backoffice/reports/:id/status {status: 'resolved'}
|
||
BE-->>GW: 200 AdminReport
|
||
```
|
||
|
||
### 16.9 Order creation — FUTURE (no order-creation endpoint exists anywhere yet; orders today are admin-managed post-hoc, not created via the documented API)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Shopper
|
||
participant FE as Frontend (checkout, not yet built against a real order endpoint)
|
||
participant BE as Backend
|
||
|
||
Note over Shopper,BE: No frontend code creates an AdminOrder today. Checkout today only calls the cart-payment endpoints (§16.5). Order creation as a distinct backend concept (order number, items snapshot, timeline) is a FUTURE integration point once checkout and the admin order domain are connected.
|
||
Shopper->>FE: complete checkout
|
||
FE->>BE: (future) POST /orders {items, customer, payment, shipping}
|
||
BE-->>FE: (future) 201 AdminOrder
|
||
```
|
||
|
||
---
|
||
|
||
## 17. Developer notes
|
||
|
||
### Performance
|
||
- `GET /category`/`GET /category/:id`/`GET /items/:id` are hit frequently and already client-cached (§13) — backend should still index these for fast reads (category id, item id are the obvious indexes) since the client cache is per-tab and short-lived.
|
||
- Paginated admin list endpoints (§5.4) must support `page`/`pageSize` server-side, not return-everything-then-paginate-client-side — several mock gateways currently return full arrays and let the facade slice, which will not scale once real data volumes exist.
|
||
|
||
### Scaling
|
||
- Tenant resolution happens on every request (host header lookup) — this must be a fast, cached lookup (e.g. an in-memory or Redis-backed host→tenant map), not a database round-trip per request, given it gates literally everything including the public bootstrap endpoint.
|
||
- `BackofficeDataService` (frontend) already shares one in-flight request per endpoint across multiple consumers via `shareReplay` — backend need not defend against duplicate near-simultaneous requests from a single client session, but should still handle normal concurrent-tenant load.
|
||
|
||
### Indexes
|
||
- Category: `slug` (unique per tenant), `parentId` (hierarchy traversal), `deletedAt` (soft-delete filtering).
|
||
- Product: `sku` (unique per tenant), `categoryId`, `barcode`, `visible`+`archived` (list filtering).
|
||
- Order: `orderNumber`, `customer.email` (customer aggregation, §7.3), `status`, `createdAt` (range queries for analytics).
|
||
- Session: `webSessionID`/`adminSessionID` (primary lookup key on every authenticated request).
|
||
|
||
### Transactions & concurrency
|
||
- `PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish` are whole-document writes — guard against two concurrent editors clobbering each other. No optimistic-locking field (`version`/`etag`) exists in `BootstrapConfig` today; adding one (e.g. `schemaVersion` bumped or a separate `revision` field checked on write) is recommended before multi-editor concurrent use is expected, since the current contract has no way to detect a stale-draft overwrite.
|
||
- Order status transitions (§8.1) should be guarded by a DB-level transaction covering the status write + timeline-entry append, so the two never diverge.
|
||
- Category `deletedAt`/product `archived` toggles plus any cascading effects (e.g. hiding a category's products) should be transactional if such cascades are implemented.
|
||
|
||
### Versioning
|
||
- `schemaVersion` in `BootstrapConfig` is the real version signal (§5.1) — bump it deliberately, coordinate with frontend releases for anything beyond additive optional fields.
|
||
- No API-path versioning exists (`/bootstrap`, not `/v1/bootstrap`) — introducing path versioning later is a breaking change to every existing integration; if it's ever needed, prefer a new parallel path family over renaming the existing one.
|