Compare commits
191 Commits
65c6d6f5d1
...
B2B
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a56571af | ||
|
|
55634b3b57 | ||
|
|
d44565fae9 | ||
|
|
846004e6d8 | ||
|
|
52fb52888f | ||
|
|
d4959bd4da | ||
|
|
dda0a3d2df | ||
|
|
b4772d10c7 | ||
|
|
1c87a53f02 | ||
|
|
771dce9e29 | ||
|
|
a35953d90f | ||
|
|
885f4d1299 | ||
|
|
a3808842c1 | ||
|
|
cf17b0b6c6 | ||
|
|
f9e09b1757 | ||
|
|
e8fc8480fe | ||
|
|
6e47d01c32 | ||
|
|
04272ae673 | ||
|
|
bc5f7c7a64 | ||
|
|
d27c10dd17 | ||
|
|
fd3ca85929 | ||
| 98c39f6844 | |||
| 14d46ceaa6 | |||
| 2e41e216c0 | |||
| 92f1c884c9 | |||
| bc74fa77d9 | |||
| 9cd56586fb | |||
| 3c53a6a33e | |||
| e5949c3967 | |||
| 8e58ee85f0 | |||
| 66a0ccfdb8 | |||
| 6d25172a13 | |||
| 20e03d4340 | |||
| 4288e5cd44 | |||
| 03c750cae7 | |||
| e8c48043ed | |||
| 358996cbf2 | |||
| 2602d0c838 | |||
| 640360d63c | |||
| 3f550de6b2 | |||
| f4ea4c7af8 | |||
|
|
217ab37496 | ||
|
|
bbf12cad33 | ||
|
|
c06ae56d88 | ||
|
|
2149e6435a | ||
|
|
8cdafbe62a | ||
|
|
de6bef8e9a | ||
|
|
9344f2702c | ||
|
|
e5ed1c96e5 | ||
|
|
4247a7f83f | ||
|
|
90bd05aa98 | ||
|
|
0f042fd384 | ||
|
|
f8063b320e | ||
|
|
c17d351cd1 | ||
|
|
ec949b5a19 | ||
|
|
ec4b01e1b4 | ||
|
|
9134a7ff63 | ||
|
|
c83d783ff7 | ||
|
|
a116c4f592 | ||
|
|
62d3045f0c | ||
|
|
7e7a015ff6 | ||
|
|
0df8d3d592 | ||
|
|
ffd57f2d18 | ||
|
|
7fe5ac7cd4 | ||
|
|
fc53a3b7f5 | ||
|
|
1bdca917b3 | ||
|
|
14467cc6fb | ||
|
|
8a68be797a | ||
|
|
21443d34a0 | ||
|
|
1a198252b3 | ||
|
|
c104f313ce | ||
|
|
cee5048d74 | ||
|
|
92e2ee5f49 | ||
|
|
00c7a62e51 | ||
|
|
a2204c641b | ||
|
|
c721120e85 | ||
|
|
3318b34f1e | ||
|
|
28861953c8 | ||
|
|
0089285373 | ||
|
|
71da5a8d80 | ||
|
|
7224bc56c2 | ||
|
|
551a22a245 | ||
|
|
f079ef6f52 | ||
|
|
f6045a07b2 | ||
|
|
a8f7ca31f9 | ||
|
|
2e4bb4ae00 | ||
|
|
14c72d1a6a | ||
|
|
23060261c7 | ||
|
|
be167d110e | ||
|
|
34f79b0303 | ||
|
|
5d585ff8ff | ||
|
|
0e16eecda6 | ||
|
|
a4f44dbb58 | ||
|
|
e3a70f5e65 | ||
|
|
475d75781f | ||
|
|
6ac52b1c50 | ||
|
|
5f23c6e5aa | ||
|
|
580d228484 | ||
|
|
b19fd77a60 | ||
|
|
2e09369345 | ||
|
|
ec6760ac65 | ||
|
|
707db6d43c | ||
|
|
c91b75c036 | ||
|
|
8d9eb97e9e | ||
|
|
821fecf5d3 | ||
|
|
54cd089e80 | ||
|
|
634e3faf3d | ||
|
|
6ca672987e | ||
|
|
bf367fc5fe | ||
|
|
d24ba38479 | ||
|
|
288c7ac33f | ||
|
|
ffaa6d2a1c | ||
|
|
3c72c37e31 | ||
|
|
1ebfd206ce | ||
|
|
687891cbaf | ||
|
|
65ce2ef23c | ||
|
|
e818dc6fc0 | ||
|
|
65663ad6ec | ||
|
|
aedc05110c | ||
|
|
3480efedd1 | ||
|
|
0d1d468307 | ||
|
|
3056be53db | ||
|
|
7f3a22abb8 | ||
|
|
fbc51c4866 | ||
|
|
10b2ad9023 | ||
|
|
7f9bb6aae6 | ||
|
|
1ab689e056 | ||
|
|
f1ee199d92 | ||
|
|
1032891d26 | ||
|
|
9ccd807a55 | ||
|
|
35b1c7ed27 | ||
|
|
28f39a31f6 | ||
|
|
5ed4936898 | ||
|
|
55e4938a57 | ||
|
|
cfee91355b | ||
|
|
0d0f4e5f9c | ||
|
|
0221c905f0 | ||
|
|
0cadc1a642 | ||
|
|
8b01685b48 | ||
|
|
4510eb769a | ||
|
|
414e86bfb5 | ||
|
|
d8c078ad5a | ||
|
|
07367d3183 | ||
|
|
357d346787 | ||
|
|
23d9f2f66f | ||
|
|
00e5ce6b20 | ||
|
|
a339a1c64e | ||
|
|
c9a80da7c3 | ||
|
|
bad3002006 | ||
|
|
178b5f0dc7 | ||
|
|
6cc5d43a10 | ||
|
|
b04e3a67f5 | ||
|
|
0b08802996 | ||
|
|
1af337f005 | ||
|
|
3feb806caa | ||
|
|
ec8ed8f6a8 | ||
|
|
8937aea57c | ||
|
|
c0bce7feac | ||
|
|
9f784406d7 | ||
|
|
461cd8421c | ||
|
|
6231128288 | ||
|
|
fd8e7e1b28 | ||
|
|
000bb78112 | ||
|
|
d3d6632375 | ||
|
|
1e84d67e24 | ||
|
|
c461d9bd5f | ||
|
|
63039b8707 | ||
|
|
908f10e022 | ||
|
|
49aab63124 | ||
|
|
320f1f44b7 | ||
|
|
8a91a862ca | ||
|
|
4ef5ea2f58 | ||
|
|
4c4417dc1d | ||
|
|
ebca66dd4c | ||
|
|
c7d8ef1295 | ||
|
|
f336420415 | ||
|
|
9fa3321322 | ||
|
|
bac415d003 | ||
|
|
0646d587eb | ||
|
|
c3b5820ac9 | ||
|
|
570e3f3c36 | ||
|
|
e6d64abd56 | ||
|
|
5d47101714 | ||
|
|
7a2f2a452f | ||
|
|
6d075fc5b9 | ||
|
|
a95ca37a4b | ||
|
|
ce63931bc2 | ||
|
|
6f9401fa8f | ||
|
|
55b379bd6d | ||
|
|
3e3185cb6e | ||
|
|
48bcffa22c |
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto
|
||||
|
||||
# Anything executed by a Linux shell must keep LF endings. A CRLF checkout
|
||||
# makes bash fail with "\r: command not found" on the very first line.
|
||||
*.sh text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# systemd chokes on trailing CR in unit values.
|
||||
*.service text eol=lf
|
||||
*.timer text eol=lf
|
||||
31
.github/workflows/architecture-governance.yml
vendored
31
.github/workflows/architecture-governance.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install Dependencies
|
||||
@@ -26,5 +26,34 @@ jobs:
|
||||
- name: Enforce Boundaries
|
||||
run: npm run arch:check
|
||||
|
||||
# Was entirely missing before 2026-08-18: this workflow built and
|
||||
# checked boundaries but never ran a single test. karma.conf.js's
|
||||
# CHROME_BIN fallback is a Windows path, which the ubuntu-latest
|
||||
# runner doesn't have - browser-actions/setup-chrome supplies one
|
||||
# and CHROME_BIN below points at it explicitly.
|
||||
- name: Setup Chrome
|
||||
id: setup-chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
|
||||
- name: Unit tests with coverage gate
|
||||
env:
|
||||
CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
run: npm run test:coverage
|
||||
|
||||
# The production build is what enforces the bundle budget. The initial
|
||||
# bundle sits at ~1.55 MB raw against a 700 kB target, so the error
|
||||
# threshold is a ratchet, not the goal: it is set just above today's
|
||||
# size so the bundle cannot grow while we work it back down. Lower the
|
||||
# ratchet in angular.json every time it comes down.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# Stops payment credentials returning to the browser bundle. See
|
||||
# scripts/ci/scan-bundle.sh for what it looks for and why.
|
||||
- name: Scan bundle for credentials
|
||||
run: npm run scan:bundle
|
||||
|
||||
- name: E2E
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npm run e2e
|
||||
|
||||
196
.github/workflows/deploy.yml
vendored
Normal file
196
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
# Multi-tenant: one bundle serves every customer domain, so a single deploy
|
||||
# updates all of them at once. There is no per-tenant build or per-tenant deploy.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Branch or SHA to deploy
|
||||
required: false
|
||||
default: main
|
||||
reconcile_api_domains:
|
||||
description: >-
|
||||
Also provision api.<base-domain> nginx vhosts and TLS. Off by default:
|
||||
existing API domains are configured by hand, and re-running the helper
|
||||
writes a second server block for a server_name that already has one.
|
||||
Turn this on only when adding a NEW base domain.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontend
|
||||
cancel-in-progress: false # never abandon a half-finished release swap
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Enforce boundaries
|
||||
run: npm run arch:check
|
||||
|
||||
- name: Build
|
||||
run: npm run build -- --configuration production
|
||||
|
||||
- name: Resolve build output
|
||||
id: dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# @angular/build:application emits into dist/<name>/browser.
|
||||
# Fall back to the flat layout so this survives a builder change.
|
||||
if [ -d dist/dexarmarket/browser ]; then
|
||||
DIR=dist/dexarmarket/browser
|
||||
elif [ -f dist/dexarmarket/index.html ]; then
|
||||
DIR=dist/dexarmarket
|
||||
else
|
||||
echo "no build output found under dist/dexarmarket" >&2
|
||||
ls -R dist || true
|
||||
exit 1
|
||||
fi
|
||||
test -f "$DIR/index.html" || { echo "$DIR has no index.html" >&2; exit 1; }
|
||||
echo "dir=$DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "Deploying from $DIR ($(find "$DIR" -type f | wc -l) files)"
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$DEPLOY_SSH_KEY" || { echo "secret DEPLOY_SSH_KEY is empty" >&2; exit 1; }
|
||||
test -n "$DEPLOY_KNOWN_HOSTS" || { echo "secret DEPLOY_KNOWN_HOSTS is empty" >&2; exit 1; }
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
# Pinned host key, so a MITM or a rebuilt server fails the deploy
|
||||
# instead of being trusted silently.
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
# Opt-in only. api.<base-domain> vhosts already exist and are hand-managed;
|
||||
# the helper writes its own file per domain, so running it unconditionally
|
||||
# would give nginx two server blocks for one server_name and re-run certbot
|
||||
# against a live API on every single deploy. Frontend releases do not need
|
||||
# this step - it is for standing up a NEW base domain.
|
||||
- name: Reconcile tenant API domains
|
||||
if: ${{ inputs.reconcile_api_domains }}
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
STOREFRONT_DOMAINS: ${{ secrets.STOREFRONT_DOMAINS }}
|
||||
CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }}
|
||||
BACKEND_UPSTREAM: ${{ secrets.BACKEND_UPSTREAM }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$HOST" || { echo "secret DEPLOY_HOST is empty" >&2; exit 1; }
|
||||
test -n "$USER" || { echo "secret DEPLOY_USER is empty" >&2; exit 1; }
|
||||
test -n "$STOREFRONT_DOMAINS" || { echo "secret STOREFRONT_DOMAINS is empty" >&2; exit 1; }
|
||||
test -n "$CERTBOT_EMAIL" || { echo "secret CERTBOT_EMAIL is empty" >&2; exit 1; }
|
||||
BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-https://127.0.0.1:445}"
|
||||
[[ "$CERTBOT_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || {
|
||||
echo "CERTBOT_EMAIL is invalid" >&2; exit 1;
|
||||
}
|
||||
[[ "$BACKEND_UPSTREAM" =~ ^https?://[A-Za-z0-9.:-]+$ ]] || {
|
||||
echo "BACKEND_UPSTREAM is invalid" >&2; exit 1;
|
||||
}
|
||||
|
||||
declare -A API_BASE_DOMAINS=()
|
||||
for storefront in $STOREFRONT_DOMAINS; do
|
||||
[[ "$storefront" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
|
||||
echo "invalid storefront domain: $storefront" >&2; exit 1;
|
||||
}
|
||||
IFS=. read -ra labels <<< "$storefront"
|
||||
label_count=${#labels[@]}
|
||||
take=2
|
||||
tld=${labels[label_count-1]}
|
||||
second_level=${labels[label_count-2]}
|
||||
if (( label_count >= 3 && ${#tld} == 2 && ${#second_level} <= 3 )); then
|
||||
take=3
|
||||
fi
|
||||
start=$((label_count - take))
|
||||
base_domain=$(IFS=.; echo "${labels[*]:start}")
|
||||
API_BASE_DOMAINS["$base_domain"]=1
|
||||
done
|
||||
|
||||
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
||||
for domain in "${!API_BASE_DOMAINS[@]}"; do
|
||||
$SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \
|
||||
--domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM"
|
||||
done
|
||||
|
||||
- name: Upload release
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
SRC: ${{ steps.dist.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE="${GITHUB_SHA::12}"
|
||||
echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
||||
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
||||
$SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend"
|
||||
tar -C "$SRC" -czf - . | $SSH "$USER@$HOST" \
|
||||
"tar -xzf - -C /srv/marketplaces/releases/$RELEASE/frontend"
|
||||
|
||||
- name: Activate release
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" bash -euo pipefail <<EOSSH
|
||||
BASE=/srv/marketplaces
|
||||
REL="\$BASE/releases/$RELEASE"
|
||||
test -f "\$REL/frontend/index.html" || { echo "upload incomplete, refusing to swap" >&2; exit 1; }
|
||||
# ln -T onto a temp name then mv: the swap is atomic, so no request
|
||||
# is ever served from a half-updated root.
|
||||
ln -sfnT "\$REL" "\$BASE/current.new"
|
||||
mv -Tf "\$BASE/current.new" "\$BASE/current"
|
||||
sudo /bin/systemctl reload nginx
|
||||
# Keep the last 5 releases so a rollback is a symlink change.
|
||||
ls -1dt "\$BASE"/releases/*/ | tail -n +6 | xargs -r rm -rf
|
||||
echo "active: \$(readlink -f \$BASE/current)"
|
||||
EOSSH
|
||||
|
||||
- name: Verify
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" \
|
||||
'curl -fsS -o /dev/null -w "health=%{http_code}\n" http://127.0.0.1/health &&
|
||||
curl -fsS -o /dev/null -w "index=%{http_code}\n" http://127.0.0.1/'
|
||||
|
||||
- name: Report
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "Deployed ${GITHUB_SHA::12} to ${{ secrets.DEPLOY_HOST }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Deploy of ${GITHUB_SHA::12} FAILED. The previous release is still active — the symlink only moves after a successful upload." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
packages/*/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
@@ -74,3 +75,7 @@ docs/context/schema/route.schema.json
|
||||
docs/context/schema/strategy.schema.json
|
||||
docs/context/schema/work-state.schema.json
|
||||
docs/context/schema/workspace.schema.json
|
||||
|
||||
# Playwright artifacts
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
611
BACKEND-API-REFERENCE.md
Normal file
611
BACKEND-API-REFERENCE.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# Backend API Reference
|
||||
|
||||
One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational.
|
||||
|
||||
**For what doesn't exist yet:** this doc describes the live surface only. The full set of forward-looking wire contracts for Product Plan v3.1 (money/FX, orders, catalog/offer split, connectors, seller portal, identity, tenant registry, RBAC, analytics — 10 phases + 2 tracks) lives in [docs/backend/](docs/backend/README.md).
|
||||
|
||||
Maturity tags used throughout:
|
||||
|
||||
| Tag | Meaning |
|
||||
|---|---|
|
||||
| **LIVE** | Real `HttpClient` call exists in code today, hits a real endpoint. |
|
||||
| **MOCK-SWAPPABLE** | Interface + DI token exist; a real implementation can be dropped in without touching UI. May or may not have a real impl yet. |
|
||||
| **MOCK-ONLY (no seam)** | A mock/local implementation exists but the facade injects the concrete mock class directly — no DI token. A backend needs a token introduced first before it can be wired in. |
|
||||
| **LOCAL-ONLY** | Never talks to a backend by design — localStorage / in-memory / derived from bootstrap. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Core principles
|
||||
|
||||
1. **No response envelope.** There is no `{ success, data, error }` wrapper anywhere. Every call is typed to the bare payload — `HttpClient.get<Item>(...)`, `get<Category[]>(...)`, `get<BootstrapConfig>(...)`. Success = the raw resource (object, array, or `{ items, total }` for lists). Do not wrap new endpoints in an envelope unless it's a deliberate, coordinated breaking change.
|
||||
2. **No API versioning.** No `/v1/` segment, no `Accept-Version` header, anywhere. The only version field in the whole contract is `BootstrapConfig.schemaVersion`, and it's checked for presence only, not semantically enforced.
|
||||
3. **No WebSocket / SSE.** Every "live" feeling feature (QR login polling, payment status) is plain `setInterval`/RxJS polling against a normal request/response endpoint.
|
||||
4. **Tenant resolution is 100% by hostname, not by header or path.** `TenantResolverService` reads the first DNS label (skipping `www`) and uses it to pick a base URL. No `X-Tenant` header, no `/tenant/{id}/...` prefix, ever. Auth requests carry no tenant identifier either — origin is the only signal.
|
||||
5. **Two independent API bases exist**, plus a third for auth:
|
||||
- Marketplace/tenant API — `ApiConfigService.getBaseUrl()` — default `https://api.dexarmarket.ru:445` (or per-tenant subdomain), `/api` on localhost.
|
||||
- Payment/QR API — `environment.qrApiUrl` = `https://qr.vitanova.network/api`.
|
||||
- Session auth API — `environment.authApiUrl` (currently same host as the marketplace API).
|
||||
6. **Two independent mock mechanisms coexist — don't conflate them.** (a) `mock-data.interceptor.ts` globally short-circuits a hardcoded URL list (`/ping`, `/users/sessions*`, `/category`, `/items/*`, `/searchitems`, `/cart`, `/qr*`, `/websession/*`) when `environment.useMockData=true` — off in both shipped environments today. (b) Per-domain DI-token factories (`CONFIG_PROVIDER`, `CATEGORY_REPOSITORY`, `PRODUCT_DATA_PROVIDER`, `BACKOFFICE_DATA_PROVIDER`, `ADMIN_CATEGORIES_GATEWAY`) pick a mock vs. real class per `RuntimeProviderStrategyService`. **`PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` always resolve to the real API implementation regardless of mode** — their mock branch is dead code (`product-data-provider.token.ts:12-18`, `category-repository.token.ts:12-19`). `ADMIN_DASHBOARD_METRICS_GATEWAY` always resolves to the local/mock class the other direction — no real implementation is bound yet even though the token exists.
|
||||
7. **GET retries:** `ApiService`/`ApiCategoryRepository` wrap reads in a shared `retry({ count: 2, delay: exponential from 500ms })` — expect up to 3 attempts per read before a caller sees a failure.
|
||||
8. **Dead scaffolding, not missing files:** `src/app/core/error-handling/`, `src/app/core/guards/`, `src/app/core/interceptors/` each contain only a `.gitkeep` — reserved directory structure for a centralized error-handling layer that was never built. Every error today is handled ad hoc at the call site.
|
||||
9. **Backend engineers should not "clean up" the tolerant adapters.** `ApiService.normalizeItem()`/`normalizeCategory()` and `TelegramSessionApiService.normalizeWebSession()` accept multiple historical field-name casings/aliases on purpose (see §7 Products). A payload landing anywhere inside that tolerance envelope works; a stricter renamed shape breaks the client.
|
||||
10. **Nullable fields:** the frontend treats `null`, `undefined`, and an omitted key as the same "absent" signal everywhere except a handful of fields explicitly typed `T | null` (e.g. `AuthSession.userId`) where `null` specifically means "known to be absent." Omit or send `null` interchangeably elsewhere.
|
||||
11. **Do not invent endpoints, fields, or business rules beyond what a real frontend call already implies.** Every open question below is flagged `Requires backend decision` with a recommended default — apply the default and move on unless it's flagged as a business/security decision.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
Two **independent, coexisting** mechanisms. Neither is a stand-in for the other; they authenticate different populations today.
|
||||
|
||||
### 2a. Telegram QR / session login — customer AND admin (LIVE)
|
||||
|
||||
Single mechanism for both; only client-side storage differs (separate cookie/signals per surface). Source: `src/app/services/telegram-session-api.service.ts`.
|
||||
|
||||
| Endpoint | Method | Auth | Body / Headers | Response |
|
||||
|---|---|---|---|---|
|
||||
| `/users/sessions` | POST | none | body `{ webSessionID }` (client-generated GUID) + header `WebSessionID: <same guid>` | `{ webSessionID, url }` — `url` is a `https://t.me/{bot}?start={id}` deep link |
|
||||
| `/users/sessions/{id}` | GET | none | — | Session object, field-tolerant, normalized to `AuthSession` |
|
||||
| `/users/sessions/{id}` | DELETE | none | header `WebSessionID: <id>` | ignored — client clears local state regardless of response |
|
||||
|
||||
```http
|
||||
POST https://api.dexarmarket.ru:445/users/sessions
|
||||
WebSessionID: 3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
|
||||
Content-Type: application/json
|
||||
|
||||
{ "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" }
|
||||
```
|
||||
```json
|
||||
{ "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11", "url": "https://t.me/myAMLKYCBOT?start=3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" }
|
||||
```
|
||||
|
||||
Poll response (field-tolerant — send real field names, the client accepts many aliases):
|
||||
```json
|
||||
{
|
||||
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||||
"status": "active",
|
||||
"user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
|
||||
"expiresAt": "2026-07-26T05:00:00Z"
|
||||
}
|
||||
```
|
||||
Send a real `expiresAt`/`expires` — if absent, the client fabricates `now + 3600s`.
|
||||
|
||||
Client-side model (`src/app/models/auth.model.ts`):
|
||||
```ts
|
||||
interface AuthSession { sessionId: string; userId: number | null; username: string | null; displayName: string; active: boolean; expires: string; }
|
||||
interface WebSessionStart { webSessionID: string; url: string; }
|
||||
```
|
||||
|
||||
Expiry handling: `expires` drives a client timer that re-polls `GET /users/sessions/{id}` shortly before expiry; if the backend reports inactive, local state clears. There is no reactive 401 handling for this mechanism — expiry is only discovered on the next explicit poll.
|
||||
|
||||
### 2b. Ed25519 challenge/response admin auth (wired client-side, backend not implemented — calls 404 today)
|
||||
|
||||
Source: `src/app/core/auth/services/auth-api.service.ts`. Base `{authApiUrl}/api/admin/auth`.
|
||||
|
||||
| Endpoint | Method | Request | Response |
|
||||
|---|---|---|---|
|
||||
| `/challenge` | GET | — | `AuthChallenge { nonce, issuedAt, expiresAt }` |
|
||||
| `/verify` | POST | `VerifySignatureRequest { publicKey, signature, nonce }` | `AuthTokenPair { token, refreshToken }` |
|
||||
| `/refresh` | POST | `RefreshTokenRequest { refreshToken }` | `AuthTokenPair` |
|
||||
| `/logout` | POST | `{ refreshToken }` | void |
|
||||
|
||||
JWT claims (`JwtClaims`, decode-only client-side — the frontend never verifies the signature, that's the backend's job on every request):
|
||||
```ts
|
||||
interface JwtClaims { sub: string; role: AdminRole; iat: number; exp: number; publicKey: string; }
|
||||
type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
|
||||
```
|
||||
|
||||
Storage: `localStorage['ed25519AdminToken']` (access), `localStorage['ed25519AdminRefreshToken']` (refresh, opaque, never decoded client-side).
|
||||
|
||||
**Header:** intended as standard `Authorization: Bearer <token>`, but the interceptor that would auto-attach it (`authInterceptor`) is **not registered** in `app.config.ts` today — no request currently attaches the bearer token automatically. `adminAuthHeadersInterceptor` sets it *if* a token happens to be in storage, but nothing populates one in the live flow yet.
|
||||
|
||||
**Refresh:** client proactively refreshes ~60s before `exp` via a scheduled timer, and (once `authInterceptor` is registered) would reactively refresh once on any 401 before giving up. Every `/refresh` response is expected to return a **new** `refreshToken` (rotation) — the backend should invalidate the one just used.
|
||||
|
||||
**Role → permission table** (`ROLE_PERMISSIONS`, coarse, enforced client-side only for UX — backend must independently authorize every mutation):
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| `Owner` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` |
|
||||
| `Administrator` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` |
|
||||
| `Editor` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` |
|
||||
| `Support` | `backoffice.read` |
|
||||
| `ReadOnly` | `backoffice.read`, `builder.read` |
|
||||
|
||||
**Known naming collision:** `AdminRole` is defined twice — the string union above (`core/auth/models/permission.model.ts`, the real JWT/auth contract) and an unrelated interface in `features/admin/users/models/admin-user.model.ts` (display-only labels in the Users admin page, not connected to auth). Treat the string union as the authoritative role for auth purposes; the interface needs a rename (e.g. `AdminUserRoleRecord`) — this is flagged, not yet fixed.
|
||||
|
||||
**Route guards:** `adminAuthGuard` (live, checks only "is there an active Telegram session," no role check) gates `/edit`, `/edit/:section`, `/backoffice`. `ed25519AuthGuard` and `permissionGuard(permission)` exist and are fully built but attached to **no route today** — dormant until Mechanism B cuts over. Every guard is a client-side UX gate only; the backend must independently verify authorization on every admin mutation regardless of what a guard decided.
|
||||
|
||||
**Open decision (business, not technical — ask a human):** whether Mechanism A is retired outright in favor of Mechanism B at cutover, or both run in parallel gated by role/tenant config.
|
||||
|
||||
### 2c. Email/phone OTP login — customer (NOT IMPLEMENTED, proposed)
|
||||
|
||||
**Gap:** customer storefront login/checkout requires Telegram (Mechanism A) — shoppers without Telegram have no way to identify themselves. Raised as a real usability problem, not a hypothetical.
|
||||
|
||||
**Ask:** a third, independent auth mechanism (coexists with 2a/2b, replaces neither):
|
||||
|
||||
```
|
||||
POST /auth/otp/request
|
||||
Body: { "identifier": "user@example.com" } // or E.164 phone, e.g. "+79991234567"
|
||||
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
|
||||
```
|
||||
|
||||
```
|
||||
POST /auth/otp/verify
|
||||
Body: { "requestId": "...", "code": "482913" }
|
||||
Response (on success): {
|
||||
"sessionId": "...", "userId": 8823771, "username": null,
|
||||
"displayName": "user@example.com", "active": true, "expires": "2026-08-15T11:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The success response must be shaped identically to the existing `AuthSession` (`sessionId, userId, username, displayName, active, expires`, §2a's client model) — this lets every existing downstream consumer (guards, session signals, cart/checkout) work unchanged regardless of which mechanism produced the session.
|
||||
|
||||
Rate limiting/expiry, explicit so nothing is left to guesswork: 60s resend cooldown per identifier between `/request` calls; code expires 10 minutes after issuance; `requestId` allows up to 5 verify attempts before it's invalidated (consumed on success, on the 5th wrong attempt, or on expiry) — not single-use-per-attempt, so one mistyped digit doesn't force a full 60s wait for a new code.
|
||||
|
||||
**Error responses must use the existing envelope** (§5), with these codes on `/verify` (the client maps each to distinct UX — see the design doc):
|
||||
|
||||
| `error.code` | HTTP status | Meaning |
|
||||
|---|---|---|
|
||||
| `VALIDATION_FAILED` | 422 | Malformed identifier (`error.details[0]` names the field). |
|
||||
| `RATE_LIMITED` | 429 | Resend cooldown not yet elapsed. |
|
||||
| `CODE_EXPIRED` | 410 | 10-minute window passed. |
|
||||
| `CODE_INVALID` | 401 | Wrong code, attempts remain on this `requestId`. |
|
||||
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown, exhausted (5 wrong attempts), or expired. |
|
||||
|
||||
Admin can toggle which login methods (Telegram/Email/Phone) are shown to shoppers — this is a client-only UI gate (Admin Settings, `LocalStorageService`-persisted), not a backend flag; all endpoints stay available regardless of the toggle state.
|
||||
|
||||
See `docs/superpowers/specs/2026-08-15-email-phone-login-design.md` for the full design. No client code exists yet — nothing to build against a 404.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bootstrap — the runtime config document
|
||||
|
||||
The single payload that drives the entire multi-tenant storefront/builder/backoffice. Fetched once at app startup, held in memory; nearly every feature reads from it instead of a dedicated endpoint.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Method / Route | `GET /bootstrap` (relative, rewritten onto the tenant base) |
|
||||
| Auth | **None** — must be publicly cacheable per tenant, fetched before any login |
|
||||
| Query / body | none |
|
||||
|
||||
```ts
|
||||
interface BootstrapConfig {
|
||||
schemaVersion: string; generatedAt: string;
|
||||
tenant: TenantConfig; branding: BrandingConfig; theme: ThemeConfig; company: CompanyConfig;
|
||||
featureFlags: FeatureFlagsConfig; features?: MarketplaceFeaturesConfig;
|
||||
apiEndpoints: ApiEndpointsConfig; localization: LocalizationConfig; seo: SeoConfig;
|
||||
permissions: PermissionsConfig; header?: HeaderConfig; catalog?: CatalogConfig;
|
||||
layout?: PlatformLayoutConfig; navigation: NavigationConfig; footer?: FooterConfig;
|
||||
productPage?: ProductPageConfig; userExperience?: UserExperienceConfig;
|
||||
pages: PageConfig[]; staticPages?: StaticPagesConfig; widgetRegistry?: WidgetRegistryConfig;
|
||||
}
|
||||
```
|
||||
|
||||
Required top-level keys (must always be emitted): `schemaVersion, generatedAt, tenant, branding, theme, company, featureFlags, apiEndpoints, localization, seo, permissions, navigation, pages`. Everything marked `?` may be omitted — the client applies defaults.
|
||||
|
||||
`apiEndpoints.{website,builder,backoffice}` is where a tenant is meant to declare its per-surface endpoint paths at runtime (`Record<string, { path, method, timeoutMs? }>`) — **these are empty `{}` in the mock today; no builder/backoffice CRUD path exists as a hardcoded literal anywhere in the client.** Any concrete admin CRUD path in this document is a proposal, not a verified literal, until populated here.
|
||||
|
||||
Abridged real example (from `src/assets/mock/bootstrap/bootstrap.json`):
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"generatedAt": "2026-07-03T00:00:00Z",
|
||||
"tenant": {
|
||||
"id": "tenant-default-001", "slug": "default", "code": "DEFAULT", "host": "default.local",
|
||||
"name": "Marketplace", "websiteBaseUrl": "https://marketplace.local",
|
||||
"builderBaseUrl": "https://builder.marketplace.local", "backofficeBaseUrl": "https://backoffice.marketplace.local",
|
||||
"defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"],
|
||||
"defaultCurrency": "RUB", "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
|
||||
"timezone": "Europe/Moscow", "documentationUrl": "https://docs.marketplace.local"
|
||||
},
|
||||
"branding": { "brandName": "Marketplace", "logoUrl": "/icons/icon-192x192.png", "faviconUrl": "/favicon.ico", "supportEmail": "support@marketplace.local" },
|
||||
"theme": {
|
||||
"themeId": "default-light", "mode": "light",
|
||||
"palette": { "primary": "#497671", "secondary": "#a1b4b5", "success": "#10b981", "warning": "#f59e0b", "danger": "#ef4444", "textPrimary": "#1e3c38", "backgroundPrimary": "#ffffff", "border": "#d3dad9" },
|
||||
"typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 },
|
||||
"spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] }
|
||||
},
|
||||
"featureFlags": { "wishlist": true, "compare": true, "reviews": true, "blog": false, "chat": false, "coupons": true },
|
||||
"apiEndpoints": { "bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 }, "website": {}, "builder": {}, "backoffice": {} },
|
||||
"localization": { "defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"], "currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" } },
|
||||
"catalog": { "layout": "grid", "defaultSort": "relevance", "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"] },
|
||||
"navigation": { "header": [{ "id": "nav-home", "labelKey": "nav.home", "route": "/", "order": 1 }], "footer": [{ "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 }] },
|
||||
"widgetRegistry": { "manifestUrl": "/assets/mock/bootstrap/widget-manifest.json" },
|
||||
"pages": [{
|
||||
"id": "page-home", "key": "home", "title": "Home", "route": { "path": "/", "exact": true }, "visible": true,
|
||||
"sections": [{ "id": "section-hero", "type": "hero", "order": 1, "layout": { "strategy": "hero", "columns": 1 }, "widgets": [{ "id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1, "props": { "title": { "ru": "Добро пожаловать", "en": "Welcome" } } }] }]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Which provider fires:** mock (`GET /assets/mock/bootstrap/bootstrap.json`) when `useMockData=true`, or when `useMockBootstrapOnLocal=true` and host is localhost; otherwise real `GET /bootstrap`.
|
||||
|
||||
**No write path exists.** Publishing a marketplace (builder "Publish") only promotes an in-memory/localStorage draft signal today — nothing reaches a backend. See §8 Builder.
|
||||
|
||||
**Requires backend decision:** `X-Language`/`Accept-Language` pre-selection on this call (today the client always gets and holds the full multi-locale document); whether `schemaVersion` is ever semantically enforced (today presence-only); ETag/conditional-request caching (none exists); the entire draft→publish write path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pagination, sorting, filtering, search — conventions
|
||||
|
||||
**Two pagination styles coexist — support both, they are not interchangeable:**
|
||||
|
||||
- **Offset/count** (marketplace storefront reads) — query params `count` (page size, default 50) and `skip` (offset, default 0). `searchItems` returns `{ items, total }`; other list reads (`getCategoryItems`, `getRandomItems`) return a bare array with no total.
|
||||
- **Page/pageSize** (admin lists, storefront engagement lists, media) — request `{ page, pageSize, ...filters }`, response `{ items, total, page, pageSize }`. Client derives `totalPages = ceil(total / pageSize)` itself.
|
||||
|
||||
No cursor/keyset pagination exists anywhere. No server-side page-size cap is enforced by the client (it just sends 50 as a default) — **requires backend decision** on max page size.
|
||||
|
||||
**Sorting:** enumerated in bootstrap `catalog.availableSorts`: `relevance | latest | price_asc | price_desc | rating | popular | discount` (7 values). The live `sort` query param on `GET /searchitems` only accepts a 5-value subset: `relevance | price_asc | price_desc | popular | rating` — `latest`/`discount` have no confirmed search-endpoint mapping. **Requires backend decision** to reconcile these two vocabularies, and to define wire encoding for admin-list sorting (no convention exists yet — admin CRUD is mock-only).
|
||||
|
||||
**Filtering:** storefront search accepts `categoryIDs` (comma-joined ints), `minPrice`, `maxPrice`, `tag`. Admin list filter objects (in-memory today, not confirmed wire contracts) all follow `{ search: string, <field>: 'all' | <enum>, page, pageSize }` — `'all'` is the "no filter on this facet" sentinel. **Requires backend decision:** whether `'all'` is sent literally or the param omitted.
|
||||
|
||||
**Search:** `GET /searchitems?search=<q>&count=&skip=[&categoryIDs&minPrice&maxPrice&tag&sort]` → `{ items, total }`. No dedicated autocomplete/suggestion/trending backend endpoint exists — those are derived client-side from already-loaded catalog data today.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error model
|
||||
|
||||
**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) now reads `error.error.code` from the body when present (`authErrorCodeFromBackendCode()`), falling back to HTTP status only when no body code is sent. Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over.
|
||||
|
||||
### The envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"message": "One or more fields are invalid.",
|
||||
"status": 422,
|
||||
"requestId": "b3f1c2a0-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||||
"details": [{ "field": "sku", "code": "REQUIRED", "message": "SKU is required." }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `error.code` | yes | Stable, `UPPER_SNAKE_CASE`, never localized — this is what code should branch on, never `message`. |
|
||||
| `error.message` | yes | Human-readable English fallback only. |
|
||||
| `error.status` | yes | Mirrors the HTTP status. |
|
||||
| `error.requestId` | recommended | Correlation id for support/ops, echoed in logs. |
|
||||
| `error.details` | only on 422 | `{ field, code, message }[]` — matches the client's existing local-validation issue shape, so a future adapter can merge backend 422s into the same inline-error UI without inventing a second mechanism. |
|
||||
|
||||
### Status-by-status
|
||||
|
||||
| Status | `code` | Frontend reaction today |
|
||||
|---|---|---|
|
||||
| 401 | `UNAUTHENTICATED` | Ed25519 flow → generic "Unauthorized, sign in" screen. Customer Telegram auth: no 401 branch anywhere — session validity is only ever discovered by polling. Admin CRUD facades: none have ever seen a real 401 (all mock). |
|
||||
| 403 | `FORBIDDEN` | Ed25519 flow → "Forbidden, back to dashboard." No tenant-vs-role distinction exists — both render identical copy. |
|
||||
| 404 | `NOT_FOUND` | No code path distinguishes 404 from any other failure — a deleted product and a 500 render the identical generic empty-state today. |
|
||||
| 409 | `CONFLICT` | Nothing reacts to 409 anywhere. Only related mechanism: `AdminCategoriesGateway.isSlugTaken()`, a proactive pre-check, not a 409 handler. |
|
||||
| 422 | `VALIDATION_FAILED` + `details[]` | No admin form parses a backend validation body today (all mock). Client's own `ProjectEditorFacade.fieldError(fieldKey)` inline-error pattern is the convention to align a future adapter to. |
|
||||
| 429 | `RATE_LIMITED` (+`retryAfterSeconds`) | **Zero handling anywhere** — no interceptor, facade, or component references 429 at all. |
|
||||
| 500 | `INTERNAL_ERROR` | Falls into whatever generic catch-all a given caller has (retry-button empty state, or — for `LocationService.getRegions()` — silently falls back to 6 hardcoded regions with no visible error at all). |
|
||||
| 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. |
|
||||
| 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. |
|
||||
| 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. |
|
||||
| 401 (token expired) | `TOKEN_EXPIRED` | **Fixed** — `toAuthErrorShape()` (`core/auth/services/auth.service.ts`) now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. A backend 401 on `/refresh` sending `error.code: "TOKEN_EXPIRED"` reaches the dedicated "Session expired" screen. |
|
||||
| 401 (bad signature) | `INVALID_SIGNATURE` | **Fixed**, same mechanism — reaches the dedicated screen when the backend sends `error.code: "INVALID_SIGNATURE"`. |
|
||||
|
||||
**Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated.
|
||||
|
||||
---
|
||||
|
||||
## 6. Marketplace / storefront API (LIVE)
|
||||
|
||||
Base: `ApiConfigService.getBaseUrl()`. Headers on every call (`apiHeadersInterceptor`): `X-Region`, `X-Language` (`ru→RU, en→EN, hy→AM`), `Currency` (default `RUB`), `WebSessionID`. Source: `src/app/services/api.service.ts`.
|
||||
|
||||
| Endpoint | Method | Params / Body | Response |
|
||||
|---|---|---|---|
|
||||
| `/ping` | GET | — | `{ message }` |
|
||||
| `/category` | GET | — | `Category[]` (normalized) |
|
||||
| `/category/{id}` | GET | `count`, `skip` | `Item[]` |
|
||||
| `/items/{id}` | GET | — | `Item` |
|
||||
| `/items/randomitems` | GET | `count`, `category?` | `Item[]` (featured/random) |
|
||||
| `/searchitems` | GET | `search`, `count`, `skip`, `categoryIDs?`, `minPrice?`, `maxPrice?`, `tag?`, `sort?` | `{ items: Item[], total: number }` |
|
||||
| `/websession/{sessionId}` | POST | item array | cart echo |
|
||||
| `/items/{id}/callback` | POST | `{ rating, comment, sessionID, timestamp }` | `{ message }` — review |
|
||||
| `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }` — **literal typo `questiion`, preserve it, matches the client** |
|
||||
| `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
|
||||
| `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error |
|
||||
| `/geo/resolve` | GET | — | `GeoIpResponse` — **not built yet**, see below |
|
||||
|
||||
**`/geo/resolve` — new, required.** Resolves the *caller's* IP to a coarse location so the storefront can pre-select a region. The server reads the client IP (behind the proxy, so honour `X-Forwarded-For` with `trustProxy`); the browser sends nothing and receives no third-party payload.
|
||||
|
||||
Response is the existing `GeoIpResponse` shape (`src/app/models/location.model.ts`): `{ city, country, countryCode, region?, timezone?, lat?, lon? }`.
|
||||
|
||||
Rules:
|
||||
- City-level precision only. Do not return coordinates finer than the city centroid, and do not persist the lookup against a customer record — this runs for anonymous visitors.
|
||||
- Any failure returns a non-2xx. The client already treats every error as "stay on the manual picker", so a degraded geo provider must never block the storefront.
|
||||
- Rate-limit per IP; it is an unauthenticated endpoint.
|
||||
|
||||
This replaces a direct browser call to `http://ip-api.com`, which leaked every visitor's IP to a third party and — being plaintext on an HTTPS origin — was blocked as mixed content, so region auto-detect never actually worked in production. Until this endpoint ships the client silently falls back to the manual region picker, which is the same behaviour production has had all along.
|
||||
|
||||
### 6.1 Products — the tolerance contract
|
||||
|
||||
The wire DTO `Item` (`src/app/models/item.model.ts`) is reconciled by `ApiService.normalizeItem()` — the single largest inline adapter in the codebase. It tolerates **two historical shapes at once**:
|
||||
|
||||
- `id` (string) ↔ `itemID` (numeric)
|
||||
- `imgs[]` ↔ `photos[]`
|
||||
- `names[]` ↔ `translations`
|
||||
- `description` as a key/value array ↔ a plain string
|
||||
- `comments` ↔ `callbacks` (reviews)
|
||||
- color `0xRRGGBB` → normalized `#RRGGBB`
|
||||
- `remaining` count → a stock band
|
||||
|
||||
**A real backend can send either historical shape — do not invent a third, cleaner shape.** `normalizeCategory()` does the same job for categories.
|
||||
|
||||
### 6.2 Categories — two parallel stacks exist
|
||||
|
||||
- **Clean stack (real, LIVE):** `GET /category` → `CategoryDto[]` (`{ categoryID, names: [{lang,name}], subcategories: [...] }`) → `CategoryMapper` flattens the tree, dedupes by id, normalizes `am→hy` → domain `Category`.
|
||||
- **Legacy stack:** the same `/category` response also feeds `ApiService.normalizeCategory()` → a *different* `Category` type (`src/app/models/category.model.ts`). **Two unrelated `Category` types exist in the codebase with the same name** — a known duplication, not a bug to silently fix on the backend side; just be aware both consume the same wire shape.
|
||||
|
||||
```json
|
||||
[{ "categoryID": 12, "names": [{ "lang": "ru", "name": "Электроника" }, { "lang": "en", "name": "Electronics" }], "subcategories": [{ "categoryID": 34, "names": [{ "lang": "en", "name": "Phones" }] }] }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Cart / Orders / Payments (LIVE)
|
||||
|
||||
Cart **contents** are LOCAL-ONLY (localStorage `marketplace_cart`, + Telegram CloudStorage in-app) — there is no backend cart. Checkout produces real payment + order calls.
|
||||
|
||||
| Endpoint | Method | Base | Body | Response |
|
||||
|---|---|---|---|---|
|
||||
| `/cart` | POST | marketplace | `CartPaymentRequest` | `QrCreateResponse` |
|
||||
| `/orders` | POST | marketplace | `CreateOrderRequest` | `CreateOrderResponse` — fire-and-forget after payment succeeds, doesn't touch the payment call chain |
|
||||
| `/qr` | POST | `qrApiUrl` | `QrCreateRequest` (headers `authorization-key`, `userid-value`) | `QrCreateResponse` |
|
||||
| `/qr/dynamic/{partnerId}/{qrId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
|
||||
| `/card/{partnerId}/{orderId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
|
||||
|
||||
Const `partnerId` = `web-97ec-9c57-4dde-9037-3a68f7f83750`.
|
||||
|
||||
```ts
|
||||
interface CartPaymentRequest {
|
||||
amount: number; currency: 'RUB'; siteuserID: string; siteorderID: string; redirectUrl: string;
|
||||
telegramUsername: string; paymentMethod: 'qr' | 'card'; qrDescription?: string; customerID?: string;
|
||||
items: Array<{ itemID: number; price: number; name: string; quantity?: number }>;
|
||||
}
|
||||
interface CreateOrderRequest {
|
||||
items: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
||||
customer: { name: string; email: string; phone: string };
|
||||
payment?: { method: string; currency: string };
|
||||
shipping?: { address: string; method: string; trackingNumber: string };
|
||||
}
|
||||
interface CreateOrderResponse { id: string; orderNumber: string; status: string; total: number; currency: string; }
|
||||
```
|
||||
|
||||
`QrCreateResponse` is deliberately alias-tolerant — many casings accepted for id/url/partner fields (`qrId`/`qrID`, `nspkurl`/`nspkId`, `partnerID`/`partnerId`/`PartnerID`, etc). Pick one canonical casing on the backend; the client resolves whichever it gets.
|
||||
|
||||
```http
|
||||
POST https://api.dexarmarket.ru:445/cart
|
||||
WebSessionID: 3f1c2a0e-…
|
||||
{ "amount": 4990, "currency": "RUB", "siteuserID": "8823771", "siteorderID": "order-2026-0007", "redirectUrl": "https://marketplace.local/checkout/done", "telegramUsername": "buyer_ivan", "paymentMethod": "qr", "items": [{ "itemID": 101, "price": 4990, "name": "Wireless Keyboard", "quantity": 1 }] }
|
||||
```
|
||||
```json
|
||||
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
|
||||
```
|
||||
|
||||
**Payments were frozen; unfrozen 2026-08-17** (Sprint 0.1 decision, see `docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`). This call chain is now in scope for the Phase 1 rework specified in `docs/backend/BACKEND-INTEGRATION.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin (backoffice) domains
|
||||
|
||||
**Structural finding, the single most important fact in this section:** of 11 admin gateway domains, only **Categories** and **Dashboard-metrics** are bound through a DI token — a real backend can be dropped in for those two with zero facade changes. **Every other admin domain's facade injects its mock `*LocalGateway` class directly**, so a token has to be added before a real backend can be wired in at all, regardless of whether the endpoint itself is easy to build. Only one real admin HTTP implementation exists anywhere: `AdminCategoriesApiGateway`.
|
||||
|
||||
| Domain | Interface | Real impl? | DI token? | Facade | Seam status |
|
||||
|---|---|---|---|---|---|
|
||||
| Categories | `AdminCategoriesGateway` | **yes** (`admin-categories-api.gateway.ts`) | yes (`ADMIN_CATEGORIES_GATEWAY`) | `AdminCategoriesFacade` | MOCK-SWAPPABLE, done |
|
||||
| Dashboard metrics | `AdminDashboardMetricsGateway` | no | yes (`ADMIN_DASHBOARD_METRICS_GATEWAY`) | `AdminDashboardFacade` | MOCK-SWAPPABLE, token only |
|
||||
| Orders | `AdminOrdersGateway` | no | **none** | `AdminOrdersFacade` | MOCK-ONLY, no seam |
|
||||
| Products | `AdminProductsGateway` | no | **none** | `AdminProductsFacade` | MOCK-ONLY, no seam |
|
||||
| Users | `AdminUsersGateway` | no | **none** | `AdminUsersFacade` | MOCK-ONLY, no seam |
|
||||
| Transactions | `AdminTransactionsGateway` | no | **none** | `AdminTransactionsFacade` | MOCK-ONLY, no seam |
|
||||
| Monitoring | `AdminMonitoringGateway` | no | **none** | `AdminMonitoringFacade` | MOCK-ONLY, no seam |
|
||||
| Moderation | `AdminModerationGateway` | no | **none** | `AdminModerationFacade` | MOCK-ONLY, no seam |
|
||||
| Customers | *(none — derived)* | no | n/a | `AdminCustomersFacade` | derives from Orders' mock gateway |
|
||||
| Analytics | *(none — derived)* | no | partial | `AdminAnalyticsFacade` | composes 5 other gateways, no data source |
|
||||
| Media | abstract class `MediaRepository` | no | yes (class token) | `MediaLibraryFacade` | MOCK-SWAPPABLE |
|
||||
|
||||
### Gateway interface method contracts (what a real backend must satisfy)
|
||||
|
||||
- **Categories** — `loadCategories(filters)`, `loadCategory(id)`, `createCategory`, `updateCategory`, `deleteCategory`, `restoreCategory`, `isSlugTaken(slug, excludingId)`.
|
||||
- **Dashboard metrics** — `loadMetrics(): AdminDashboardMetrics` (no params — a seller/scope filter would need a new parameter, no object to extend).
|
||||
- **Orders** — `loadOrders(filters)`, `loadOrder(id)`, `updateStatus(id, status)`, `requestRefund(id)`, `addNote(id, note, internal)`, `archiveOrder`, `restoreOrder`, `deleteOrder`.
|
||||
- **Products** — `loadProducts(filters)`, `loadProduct(id)`, `loadCategories()`, `createProduct`, `updateProduct`, `deleteProduct`, `duplicateProduct`, `archiveProduct`, `restoreProduct`.
|
||||
- **Users** — `loadUsers`, `loadRoles`, `loadInvitations`, `loadSessions(userId)`, `loadAudit(userId)`, `setUserRole`, `setUserStatus`, `inviteUser(email, roleId, scope)`, `revokeInvitation`, `revokeSession`.
|
||||
- **Transactions** — `loadTransactions(filters)`, `retryFailed(id)`, `setFraudFlag(id, flagged)`.
|
||||
- **Monitoring** — `loadEvents(filters)`, `loadQueues()`, `loadWebhooks()`.
|
||||
- **Moderation** — `loadReviews(filters)`, `loadReview(id)`, `setReviewStatus`, `setReviewVisible`, `setReviewPinned`, `setReviewFeatured`, `addModeratorNote`, `deleteReview`, `loadReports()`, `setReportStatus(id, status)`.
|
||||
- **Media** — `list(params?)`, `upload(file, options?)`, `remove(id)`, `update(id, patch)`, `listFolders()`.
|
||||
|
||||
### The one real admin endpoint — Categories, exact paths
|
||||
|
||||
Base: `${apiConfig.getBaseUrl()}/backoffice/categories`. Mode-switched between this and the local mock via `getCategoryProviderMode()` — mock in local dev (no reachable backoffice API there), real API in production.
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `/backoffice/categories?search=&visibility=&includeDeleted=` | — | `AdminCategory[]` |
|
||||
| GET | `/backoffice/categories/{id}` | — | `AdminCategory \| null` (404 → null) |
|
||||
| POST | `/backoffice/categories` | `AdminCategory` minus `{id, itemsCount, deletedAt, createdAt, updatedAt}` | `AdminCategory` |
|
||||
| PUT | `/backoffice/categories/{id}` | full `AdminCategory` | `AdminCategory` |
|
||||
| DELETE | `/backoffice/categories/{id}` | — | `void` — **soft delete only, no hard delete exists** |
|
||||
| POST | `/backoffice/categories/{id}/restore` | `{}` | `AdminCategory \| null` |
|
||||
| GET | `/backoffice/categories/slug-taken?slug=&excludingId=` | — | `{ taken: boolean }` |
|
||||
|
||||
Use this exact path shape as the template for every other admin domain in §8.5's build order — it's the only one proven end-to-end.
|
||||
|
||||
### Worked example — Admin Orders (no mapper exists yet, backend has freedom here)
|
||||
|
||||
Unlike Categories/Products (which have a wire DTO to match), admin domains other than Categories have **no wire DTO and no mapper today** — the mock gateways build view models directly in memory. This means the JSON shape below is a *proposal* the new `AdminOrdersApiGateway` would map into the existing `AdminOrder` view model, not a shape already fixed by an adapter:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ord_1042", "status": "processing", "paymentStatus": "paid",
|
||||
"customer": { "id": "cus_88", "name": "…", "email": "…" },
|
||||
"items": [{ "productId": "…", "title": "…", "qty": 2, "unitPrice": 1990 }],
|
||||
"shipping": { "method": "…", "address": "…" },
|
||||
"timeline": [{ "event": "created", "at": "2026-07-01T10:00:00Z" }]
|
||||
}
|
||||
```
|
||||
|
||||
The same "no mapper exists, write one inside the new `*ApiGateway`" note applies to Products, Users, Transactions, Monitoring, Moderation.
|
||||
|
||||
### Widget manifest (LIVE — static/remote JSON, separate from admin CRUD)
|
||||
|
||||
`GET <bootstrap.widgetRegistry.manifestUrl>` (default `/assets/mock/bootstrap/widget-manifest.json`), falls back to `{ widgets: [] }` on any error, never throws to the UI.
|
||||
|
||||
```json
|
||||
{ "widgets": [{ "type": "hero", "version": "1.0.0", "componentKey": "HeroWidgetComponent", "supportedLayouts": ["hero"], "supportedDataSources": ["manual"], "settingsSchema": { "type": "object", "properties": { "title": { "type": "string" } } }, "defaultSettings": { "title": "Welcome" }, "enabled": true }] }
|
||||
```
|
||||
|
||||
### Backoffice storefront cards (LIVE — distinct from admin CRUD above)
|
||||
|
||||
`GET /api/backoffice/products`, `GET /api/backoffice/categories` — feeds storefront product/category card widgets, not the admin panel.
|
||||
|
||||
---
|
||||
|
||||
## 9. Everything that is LOCAL-ONLY (no backend call exists at all)
|
||||
|
||||
Worth knowing explicitly, so nobody assumes a gateway swap will "just work" for these:
|
||||
|
||||
- **Content management / static pages (CMS)** — reads/writes `BootstrapConfig.staticPages` in-memory. No dedicated backend call. Publishing = writing bootstrap back, for which no client write call exists.
|
||||
- **Project editor / builder** — edits an in-memory `BootstrapConfig`, persists drafts to `localStorage` only. "Publish" today only promotes the local draft signal. A builder API is declared only as an empty `apiEndpoints.builder: {}` placeholder in bootstrap.
|
||||
- **Search** — `SearchFacade` is a client-side orchestration over the product/category providers (history, trending, autocomplete, cache all local). The only real backend traffic underneath it is `GET /searchitems`.
|
||||
- **User experience (wishlist/compare/recently-viewed/saved-searches)** — fully denormalized objects in `localStorage`, guest-first. A DI token exists for a future authenticated repository, but nothing is bound to it — comment in code notes it "can be switched to authenticated repository later."
|
||||
- **Diagnostics** — inspects runtime/bootstrap/widget state locally; the one live-ish probe is a `/ping` health check.
|
||||
- **Cart contents** — see §7, real payment/order calls exist, cart *state* never round-trips to a backend.
|
||||
- **Currency conversion / display rates** — `CurrencyRatesService` holds RUB-based conversion rates in-memory, admin-editable via Admin Settings, persisted to `localStorage` only. `CurrencyConvertPipe` applies them client-side wherever a storefront price is rendered. The `Currency` request header (§6) is still sent on every call, but nothing round-trips a rate from the backend — see §12.7.
|
||||
|
||||
---
|
||||
|
||||
## 10. Backend build order (dependency-driven, not document order)
|
||||
|
||||
1. **Auth + session** — blocks everything admin-gated.
|
||||
2. **Bootstrap content** (branding/theme/nav/seo) — transport (`GET /bootstrap`) already works; the *content* is still default stubs. Tenant resolution depends on it.
|
||||
3. **Categories** — already LIVE both storefront and admin; products reference categories.
|
||||
4. **Products / catalog** — storefront reads are LIVE; admin Products CRUD is the first no-seam admin domain to build.
|
||||
5. **Media** — products/categories editors reference media assets.
|
||||
6. **Cart / Orders / Transactions** — checkout is LIVE; admin Orders CRUD, then Transactions (derives from Orders).
|
||||
7. **Reviews / Moderation** — customer writes are LIVE; admin Moderation gates them.
|
||||
8. **Users / roles / invitations** — independent of commerce, needs auth.
|
||||
9. **Dashboard metrics, then Monitoring** — operational visibility layers.
|
||||
10. **Analytics — last.** Needs orders/products/moderation real *and* a tracking pipeline that doesn't exist yet anywhere (not just a missing endpoint — no data source at all).
|
||||
11. **Builder draft/publish + CMS** — net-new write paths, can proceed in parallel once bootstrap content (step 2) is real.
|
||||
12. **User-experience sync, search suggestions** — enhancements over already-working local features.
|
||||
|
||||
Per-domain migration pattern for the six no-seam admin domains (Orders, Products, Users, Transactions, Monitoring, Moderation): add a DI token → switch the facade to inject the token instead of the concrete mock class → implement the `*ApiGateway` (contains the DTO→view-model mapper) → bind the token → retire or keep the mock behind the existing `useMockData` flag. This is the exact pattern already proven by Categories — replicate it, don't redesign it per domain.
|
||||
|
||||
---
|
||||
|
||||
## 11. Known discrepancies to reconcile before/while building
|
||||
|
||||
- **`AdminRole` defined twice** with unrelated shapes (§2b) — auth string-union vs. Users-page display interface.
|
||||
- **`Category` defined twice** (§6.2) — legacy vs. clean-stack, both fed by the same `/category` response.
|
||||
- **Duplicate search models** under `features/search/models/` and `core/search/models/`.
|
||||
- **`submitQuestion` endpoint path has a literal typo** (`questiion`, not `question`) — this matches the real backend spec, do not "fix" it.
|
||||
- **The Ed25519 error-code bug** (§5) — `TOKEN_EXPIRED`/`INVALID_SIGNATURE` screens are fully built and unreachable from real HTTP responses today because the client only reads HTTP status, never a body code. Needs a coordinated backend + frontend fix, not backend alone.
|
||||
- **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away.
|
||||
|
||||
For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md).
|
||||
|
||||
---
|
||||
|
||||
## 12. Frontend-blocked TODOs — needs backend
|
||||
|
||||
Raised during the Phase 0 security hardening pass (see the sprint plan). Each of these has a client-side mitigation already in place where one exists, but none of them close the actual gap without a backend change.
|
||||
|
||||
### 12.1 Admin role claim on the session
|
||||
|
||||
**Gap:** `adminAuthGuard` (Mechanism A, Telegram/QR) only checks "is there an active session" — the session API has no concept of admin role at all, so the frontend cannot enforce permissions server-authoritatively. Client mitigation: `AdminPermissionsService` derives a cosmetic permission set by matching the Telegram username against the mock Users domain locally — this is UI-only and trivially bypassed by calling the API directly.
|
||||
|
||||
**Ask:** either (a) add a `role` field to the existing `GET /users/sessions/{id}` response when the session belongs to a registered admin, or (b) finish Mechanism B (Ed25519 challenge/response, already wired client-side, `/challenge` and `/verify` currently 404) so the JWT `role` claim becomes real. Whichever is chosen, every admin-mutating endpoint must independently authorize the request — a role claim on the session is necessary but not sufficient.
|
||||
|
||||
Proposed minimal shape for option (a), added to the existing poll response (§2a):
|
||||
```json
|
||||
{
|
||||
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||||
"status": "active",
|
||||
"user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
|
||||
"expiresAt": "2026-07-26T05:00:00Z",
|
||||
"adminRole": "admin"
|
||||
}
|
||||
```
|
||||
`adminRole` absent/null → treat as non-admin regardless of what `/backoffice/**` UI is reachable client-side.
|
||||
|
||||
### 12.2 HttpOnly session cookie
|
||||
|
||||
**Gap:** the customer session cookie (`webSessionID`, `services/auth.service.ts`) is set via `document.cookie` from the frontend, which means it cannot be `HttpOnly` — only a `Set-Cookie` response header from the backend can set that flag, and JS-set cookies are readable by any injected script. Client mitigation: CSP hardened on all three nginx tenant blocks (was missing entirely on two of three) as defense-in-depth, but this does not close the gap.
|
||||
|
||||
**Ask:** `POST /users/sessions` and `GET /users/sessions/{id}` issue the session id via `Set-Cookie: webSessionID=…; HttpOnly; Secure; SameSite=Lax; Max-Age=…` instead of (or in addition to, during migration) returning it in the JSON body. Once that ships, the frontend stops writing `document.cookie` itself and relies on the browser sending the cookie automatically; `credentials: 'include'` needs enabling on the relevant HTTP calls.
|
||||
|
||||
### 12.3 Server-side order pricing
|
||||
|
||||
**Gap:** `POST` order creation (§7) let the client send a computed, discount-applied `price` per line item with no server-side revalidation. Client fix already shipped: `CreateOrderRequest.items` no longer sends `price` — only `{ productId, name, quantity }`.
|
||||
|
||||
**Ask:** the order-creation endpoint must price every line item itself by looking up `productId` in its own catalog (applying whatever discount/promo logic is authoritative server-side), and reject/[400] if the resulting total doesn't reconcile with what the client displayed (or just recompute and use the server total as-of-record, ignoring any client total entirely). Example of the request shape now sent:
|
||||
```json
|
||||
{
|
||||
"items": [{ "productId": "prod_1042", "name": "Sample Product", "quantity": 2 }],
|
||||
"customer": { "name": "Ivan P", "email": "ivan@example.com", "phone": "79991234567" },
|
||||
"payment": { "method": "card", "currency": "RUB" }
|
||||
}
|
||||
```
|
||||
Separately, `createCartPayment()` (payment-gateway charge creation) still sends a client-computed `amount` — that field can't simply be dropped, since it's what tells the payment provider how much to charge. That endpoint must independently revalidate `amount` against its own pricing before creating the charge, and reject on mismatch.
|
||||
|
||||
### 12.4 Real order audit trail
|
||||
|
||||
**Gap:** `AdminOrder` had no actor/audit field at all. Client fix already shipped: `AdminOrderTimelineEntry.actor` now exists and is populated from the signed-in admin's display name in the local mock gateway — but that's client-only bookkeeping with no server-side record.
|
||||
|
||||
**Ask:** when admin Orders CRUD gets a real backend (§10, step 6), every mutating endpoint (`updateStatus`, `requestRefund`, `addNote`, etc.) should record who performed the action server-side (from the authenticated session/JWT, not a client-supplied field) and return it in the order/timeline response:
|
||||
```json
|
||||
{
|
||||
"timeline": [
|
||||
{ "status": "processing", "timestamp": "2026-08-13T10:15:00Z", "eventKey": "statusChanged", "actor": "anna@dexar.market" }
|
||||
]
|
||||
}
|
||||
```
|
||||
`actor` must be derived server-side from the authenticated caller, never trusted from the request body.
|
||||
|
||||
### 12.5 Back-in-stock ("Notify Me") subscription
|
||||
|
||||
**Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way.
|
||||
|
||||
**Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today:
|
||||
```json
|
||||
{ "telegramUserId": "8823771" }
|
||||
```
|
||||
`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.
|
||||
|
||||
### 12.7 Currency conversion / FX rates
|
||||
|
||||
**Gap:** the backend has no per-currency pricing — it sends prices in one base currency (`RUB`) regardless of the `Currency` header (§6), and there's no exchange-rate endpoint. Client fix already shipped: admin manually enters a RUB-based rate per supported currency (Admin Settings → Currency rates), and every storefront price display converts client-side via that static, admin-typed number. Rates never update themselves and can drift from the real market rate.
|
||||
|
||||
**Ask:** this was raised as a real accounting concern (bank settlement totals not reconciling against order counts) — two options, not mutually exclusive:
|
||||
1. Backend returns prices already converted per the `Currency` header (removes client-side conversion entirely, most correct).
|
||||
2. Backend exposes a live/periodically-updated FX-rate endpoint (e.g. pegged to Rapira or another exchange) that the frontend polls instead of relying on an admin-typed static number — smaller change, keeps pricing display client-side but removes the manual-entry drift.
|
||||
Either way, the *authoritative* amount charged (`createCartPayment`'s `amount`, §12.3) must be computed/validated server-side against whichever rate source is authoritative — a client-side conversion (current or future) must never be trusted for the actual charge amount.
|
||||
|
||||
### 12.8 Admin purchase notifications depend on Orders CRUD being real
|
||||
|
||||
**Gap:** `AdminOrderWatcherService` (new — polls for new orders to toast/badge the admin) polls `AdminOrdersGateway.loadOrders()` (§8), which is bound to the mock `AdminOrdersLocalGateway` — a static, 24-row in-memory seed with no create path (see §8's gateway table, "Orders … MOCK-ONLY, no seam"). No genuinely new order can ever appear today, so the feature is functionally inert until Orders CRUD gets a real backend (§10 step 6).
|
||||
|
||||
**Ask:** nothing new beyond what §10/§11 already ask for — once a real `AdminOrdersApiGateway` is bound, this feature starts working with no additional frontend change. Flagging here only so nobody spends time debugging "why doesn't the notification ever fire" against the mock.
|
||||
|
||||
### 12.9 Admin product view counts
|
||||
|
||||
**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI.
|
||||
|
||||
**Ask:** two options, not mutually exclusive:
|
||||
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
|
||||
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
|
||||
|
||||
### 12.10 Trending search terms
|
||||
|
||||
**Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug.
|
||||
|
||||
**Ask:** an endpoint returning the top N search queries over some recent window, e.g.:
|
||||
```json
|
||||
{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] }
|
||||
```
|
||||
Once it exists, wire `loadTrending()` to it and map `query` -> `SearchSuggestion.title/text`.
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,34 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Dates are commit dates on the `B2B` branch.
|
||||
Recent work, newest first. Scoped to what changed and why — see `BACKEND-API-REFERENCE.md` for the backend-dependency detail on anything marked "depends on backend."
|
||||
|
||||
## [Unreleased]
|
||||
## 2026-08-15 — Admin purchase notifications
|
||||
|
||||
### Added
|
||||
Admin gets notified when a new order lands: a toast (click-to-navigate) plus an unread badge + order list on the topbar bell icon (previously an unused "no notifications" placeholder). Poll-based — the backend has no WebSocket/SSE, so this follows the same polling pattern already used for payment status.
|
||||
|
||||
- **Category management** (`feat(admin): complete category management`) — full admin CRUD for categories: hierarchy (parent/child), drag-and-drop reorder, visibility toggle, item counter, empty-category handling, soft delete + restore, draft/publish workflow with local draft recovery, unsaved-changes guard, slug validation, translations, SEO fields, breadcrumb preview, category image via the shared media picker.
|
||||
- **Product management completion** (`feat(admin): complete product management`) — archive/restore, barcode field, lightweight variants, related-products picker, gallery via the shared media picker, discounted-price preview, infinite-scroll list mode; products now source their category list from the new category management module instead of a separate mock.
|
||||
- **Media system hardening** (`feat(media): reusable media management`) — folder tagging, tag editing, upload validation (size/type), SVG sanitization (script/event-handler stripping), automatic image compression/resize on upload; the shared media picker is now wired into category images, product gallery, and Project Editor branding (logo/compact logo/favicon).
|
||||
- **Order management** (`feat(admin): order management`) — new admin module: order list (search/status/pagination/CSV export) and detail view (customer/payment/shipping, itemized total, status timeline, change-status, refund request, cancel, customer + internal notes, print invoice). Seeded with synthetic mock orders — no backend order domain exists yet.
|
||||
- **Transaction management** (`feat(admin): transaction management`) — payments/refunds/QR transaction list derived from the mock order data, with status/type filters, retry-failed, fraud flagging, per-transaction audit log, CSV export.
|
||||
- **Users & permissions** (`feat(admin): users and permissions`) — new admin module: users (marketplace vs office admin scope), 4 built-in roles, invitations, per-user mock session list with revoke, per-user audit log. Confirms passwordless (Telegram QR) admin login was already real and links to it rather than reimplementing.
|
||||
- **Monitoring center** (`feat(admin): monitoring center`) — unified audit/security/login/failed-login/API/error/warning event feed, mock queue and webhook-delivery views, and a Health section that reuses the existing real dashboard health checks.
|
||||
- **Analytics dashboard** (`feat(admin): analytics dashboard`) — revenue/orders/average-order-value/top-products computed from the mock order data, product/category counts from their respective modules, a sales-over-time bar chart with 7/30/90-day ranges, CSV export. Visitor/funnel/heatmap sections show an explicit "awaiting backend integration" state rather than fabricated numbers, since no analytics pipeline exists.
|
||||
- `AdminOrderWatcherService` (new) polls `AdminOrdersGateway.loadOrders()` on an admin-editable interval (default 15s, editable in Admin Settings), diffs against persisted "last notified"/"last acknowledged" order pointers, and fires toasts only for genuinely new orders — never spams on first load.
|
||||
- `UserNotificationService` gained an optional click-to-navigate `route` so a toast can jump straight to the order detail page.
|
||||
- Reactively stops/starts polling off `AdminAuthService.isAuthenticated()` — a first attempt at this (tying it to component destruction) turned out not to work because logout never navigates or destroys the admin shell; caught and corrected before merge.
|
||||
- **Depends on backend:** the mock `AdminOrdersLocalGateway` has no create path, so this feature is functionally inert until Orders CRUD gets a real backend gateway. See `BACKEND-API-REFERENCE.md` §12.8.
|
||||
- Design/plan: `docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md`, `docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md`.
|
||||
|
||||
### Changed
|
||||
## 2026-08-14 — Currency conversion (client-side)
|
||||
|
||||
- `refactor: marketplace release polish` — accessibility pass (explicit `aria-label` on every previously-unlabeled filter `<select>` across the new admin modules), loading-skeleton consistency across admin list pages that previously rendered blank during the initial fetch, and consolidation of the admin dashboard card's custom loading shimmer onto the shared skeleton component.
|
||||
Switching currency (RUB/USD/EUR/AMD) now actually converts displayed prices, instead of just swapping the currency label next to an unchanged number.
|
||||
|
||||
- **Storefront premium UX polish** (RC-Visual-02, RC-Premium-01, RC STORE-01) — composition fixes (shared skeleton/empty-state components, undefined theme vars), visual/interaction polish (hover/focus states, color-only-signal fixes), and cleanup across Home/Catalog/Search/Product/Compare/Wishlist/Cart/Static Pages. Full history: `docs/archive/`.
|
||||
- **Performance audit** (RC PERF-01) — initial bundle 1.47 MB → 1.12 MB (−24%), biggest win from lazy-loading en/hy i18n packs; dead `items-carousel`/primeng-only component removed.
|
||||
- **WCAG 2.1 AA accessibility audit** (RC A11Y-01) — first skip link added app-wide, dialog focus-trap fixes, keyboard-operable drag-and-drop fallbacks, contrast fixes.
|
||||
- **Release-candidate walkthrough** — 2 P0s fixed: an app-wide query-param routing bug, and Backoffice Categories CRUD being completely broken end-to-end.
|
||||
- **Dead-code cleanup** — removed unregistered auth guards/interceptor, unused search-analytics service, empty backoffice scaffold directories, orphaned shared barrels/models.
|
||||
- `CurrencyRatesService` (new) holds RUB-based conversion rates, admin-editable in Admin Settings → Currency rates, persisted to `localStorage`.
|
||||
- `CurrencyConvertPipe` (new) applies the selected currency's rate wherever a storefront price renders: product cards, product detail page, quick-view dialog, delivery pricing, compare table, cart line items and totals, delivery selector.
|
||||
- Admin backoffice screens intentionally keep showing raw stored-currency values — that's the existing convention for every other admin price display.
|
||||
- Fixed a follow-on bug: the cart's delivery-selector component was using the item's *source* currency as both the display label and the conversion target, so the amount never actually converted (only the label matched) — same number shown in every currency.
|
||||
- **Depends on backend:** rates are a manually-typed admin number today, not a live exchange rate, because the backend doesn't return per-currency pricing. See `BACKEND-API-REFERENCE.md` §12.7 for the two proposed backend directions (server-side conversion, or a live FX-rate endpoint) — raised because bank settlement totals weren't reconciling against order counts, which points at a real pricing-accuracy gap, not just a display one.
|
||||
|
||||
### Please note
|
||||
---
|
||||
|
||||
Orders, transactions, users/roles, monitoring, and analytics run on realistic sample data for now — the backend endpoints for these don't exist yet (tracked in `docs/BACKEND_API.md`). Categories are fully wired to a real HTTP gateway; products and media remain local-storage-backed, ready for a real API to be plugged in behind the same interfaces.
|
||||
|
||||
### Known gaps
|
||||
|
||||
Every feature above that reads "mock/local" or "seeded" has no real backend yet — see `docs/BACKEND_API_REMAINING_WORK.md` for the full punch list (products, media, orders, transactions, users/roles, and monitoring/analytics all need real endpoints before they reflect production data; categories are already wired end-to-end). `docs/ADMIN.md` documents the architecture and trade-off decisions for each module in detail. Full current status: `docs/PROJECT_INDEX.md`, `docs/FRONTEND-ROADMAP.md`, `docs/KNOWN-ISSUES.md`.
|
||||
Earlier history: `git log`.
|
||||
|
||||
234
DESIGN.md
234
DESIGN.md
@@ -1,234 +0,0 @@
|
||||
---
|
||||
name: Marketplaces Platform
|
||||
description: Config-driven multi-tenant marketplace platform — quiet chrome, tenant-led storefronts.
|
||||
colors:
|
||||
primary: "#497671"
|
||||
primary-hover: "#3d635f"
|
||||
secondary: "#a1b4b5"
|
||||
secondary-hover: "#8da3a4"
|
||||
accent: "#a7ceca"
|
||||
accent-hover: "#91b9b5"
|
||||
text-primary: "#1e3c38"
|
||||
text-secondary: "#667a77"
|
||||
text-light: "#828e8d"
|
||||
bg-primary: "#ffffff"
|
||||
bg-secondary: "#f5f5f5"
|
||||
bg-tertiary: "#f0f0f0"
|
||||
border: "#d3dad9"
|
||||
border-dark: "#677b78"
|
||||
success: "#10b981"
|
||||
warning: "#f59e0b"
|
||||
error: "#ef4444"
|
||||
info: "#3b82f6"
|
||||
typography:
|
||||
display:
|
||||
fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
|
||||
fontSize: "clamp(2rem, 4vw, 2.75rem)"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "normal"
|
||||
headline:
|
||||
fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
|
||||
fontSize: "clamp(1.5rem, 3vw, 2rem)"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "normal"
|
||||
title:
|
||||
fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
|
||||
fontSize: "1.125rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.3
|
||||
letterSpacing: "normal"
|
||||
body:
|
||||
fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
|
||||
fontSize: "1rem"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.6
|
||||
letterSpacing: "normal"
|
||||
label:
|
||||
fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
|
||||
fontSize: "0.7rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.4
|
||||
letterSpacing: "0.4px"
|
||||
rounded:
|
||||
sm: "8px"
|
||||
md: "12px"
|
||||
lg: "13px"
|
||||
xl: "22px"
|
||||
field: "10px"
|
||||
spacing:
|
||||
xs: "4px"
|
||||
sm: "8px"
|
||||
md: "16px"
|
||||
lg: "24px"
|
||||
xl: "32px"
|
||||
components:
|
||||
button-primary:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "#ffffff"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "0.625rem 1rem"
|
||||
button-primary-hover:
|
||||
backgroundColor: "{colors.primary-hover}"
|
||||
textColor: "#ffffff"
|
||||
rounded: "{rounded.md}"
|
||||
button-secondary:
|
||||
backgroundColor: "{colors.secondary}"
|
||||
textColor: "#ffffff"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "0.625rem 1rem"
|
||||
button-ghost:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.text-primary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "0.625rem 1rem"
|
||||
card:
|
||||
backgroundColor: "{colors.bg-primary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "16px"
|
||||
input:
|
||||
backgroundColor: "{colors.bg-primary}"
|
||||
textColor: "{colors.text-primary}"
|
||||
rounded: "{rounded.field}"
|
||||
padding: "10px 12px"
|
||||
badge:
|
||||
textColor: "#ffffff"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: "2px 8px"
|
||||
---
|
||||
|
||||
# Design System: Marketplaces Platform
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**Creative North Star: "The Operator's Workbench"**
|
||||
|
||||
This is a tool before it is a brand. The platform chrome — the Project Editor, the Admin backoffice, the shared UI primitives — is a dependable workbench an operator returns to session after session to build and run a marketplace. It rewards precision and speed: state is always legible (draft vs published, saved vs unsaved, safe vs destructive), controls map visibly to what they change, and nothing on screen competes with the work. The palette is a calm Muted Pine teal-green, warm enough to feel like commerce, quiet enough to disappear behind a tenant's own theme.
|
||||
|
||||
The system is deliberately configuration-first. Every storefront is themed per tenant from a runtime `bootstrap.json`, so the platform's own identity stays neutral by design — the tenant's colors, type, and layout carry the storefront's character, and the workbench chrome recedes. Where components do appear, they are tactile and confident: solid fills, decisive hover lift, honest disabled and error states. Depth is real but restrained — surfaces sit on soft tonal shadows at rest, and structural elevation is reserved for things that genuinely float (modals, dropdowns, the save bar).
|
||||
|
||||
This system explicitly rejects three looks. It is **not dated enterprise admin** — no cluttered gray dashboards, no tiny dense tables, no 2010-era Bootstrap backoffice. It is **not a generic AI-SaaS template** — no cream/violet gradient landings, no hero-metric card rows, no tracked-uppercase eyebrows on every section, no identical icon-heading-text grids. It is **not a consumer toy** — no bubbly rounded-everything, no mascots, no candy colors, no gamified UI.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Quiet, neutral chrome so per-tenant themes lead the storefront.
|
||||
- Muted Pine teal-green primary; retail-warm but low-drama.
|
||||
- Tactile, confident components with decisive states.
|
||||
- Legible state above decoration in every tool surface.
|
||||
- WCAG 2.2 AA; contrast holds across tenant themes, not just the default.
|
||||
|
||||
## 2. Colors
|
||||
|
||||
A grounded teal-green core over cool near-white neutrals; retail warmth without shouting. The tokens below are the canonical Dexar theme — the platform default. Tenant themes (Lavero, Novo, and future tenants) override these same CSS custom properties, so components must consume the variables, never hardcode hex (ADR-008).
|
||||
|
||||
### Primary
|
||||
- **Muted Pine** (#497671): The core brand teal-green. Primary buttons, active nav, focus outlines, links, key accents. On hover it deepens to **Pine Deep** (#3d635f). Grounded and natural — the color of the workbench itself.
|
||||
|
||||
### Secondary
|
||||
- **Sage Grey** (#a1b4b5): Muted blue-grey-green for secondary actions and supporting surfaces; hover **Sage Grey Deep** (#8da3a4). Quieter than primary, never competes.
|
||||
|
||||
### Tertiary
|
||||
- **Pale Mint** (#a7ceca): Soft light accent (#91b9b5 on hover) for gentle highlights, hero gradient stops, and low-emphasis fills.
|
||||
|
||||
### Neutral
|
||||
- **Deep Pine Ink** (#1e3c38): Primary text. Tinted toward the brand hue, not pure black — carries 4.5:1+ on white.
|
||||
- **Muted Pine Grey** (#667a77): Secondary text, captions, field descriptions.
|
||||
- **Faint Pine Grey** (#828e8d): Light/tertiary text, placeholders — reserve for large or non-essential text.
|
||||
- **White** (#ffffff): Primary surface (cards, inputs, panels).
|
||||
- **Soft Grey** (#f5f5f5): App background, secondary surface.
|
||||
- **Faint Grey** (#f0f0f0): Tertiary surface, subtle fills.
|
||||
- **Divider Grey** (#d3dad9): Borders, dividers, input strokes.
|
||||
- **Border Deep** (#677b78): Stronger borders where a divider needs weight.
|
||||
|
||||
### Status
|
||||
- **Success** (#10b981), **Warning** (#f59e0b), **Error** (#ef4444), **Info** (#3b82f6): Standard semantic set, consistent across all themes. Error text darkens to #991b1b on light backgrounds for AA.
|
||||
|
||||
### Named Rules
|
||||
**The Quiet Chrome Rule.** The platform's own surfaces stay neutral so tenant themes carry storefront identity. Never introduce a platform-branded color that would fight a tenant's palette.
|
||||
|
||||
**The Variable-Only Rule.** Components and widgets consume CSS custom properties (`--primary-color`, `--text-primary`, `--border-color`) only. A hardcoded hex in a component is a bug (ADR-008) — it breaks per-tenant theming.
|
||||
|
||||
## 3. Typography
|
||||
|
||||
**Display / Body Font:** DM Sans (with `-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif` fallback)
|
||||
**Label Font:** DM Sans (same family, tracked and uppercased for badges)
|
||||
|
||||
**Character:** One family, four weights (400/500/600/700). DM Sans is a low-contrast geometric-humanist sans — clean, legible at dense sizes, neutral enough to sit behind tenant content. Hierarchy comes from weight and size, never a second display face.
|
||||
|
||||
### Hierarchy
|
||||
- **Display** (700, clamp(2rem, 4vw, 2.75rem), 1.25): Page-level headings, storefront hero titles. Never exceeds ~2.75rem — the workbench does not shout.
|
||||
- **Headline** (700, clamp(1.5rem, 3vw, 2rem), 1.25): Section headings, admin page titles.
|
||||
- **Title** (600, 1.125rem, 1.3): Card titles, editor section labels, form group headings.
|
||||
- **Body** (400, 1rem, 1.6): Default reading text. Cap prose at 65–75ch.
|
||||
- **Label** (600, 0.7rem, 1.4, letter-spacing 0.4px, uppercase): Badges and tags only — the one place tracked uppercase is legitimate.
|
||||
|
||||
### Named Rules
|
||||
**The One Family Rule.** DM Sans in multiple weights carries the entire system. Do not pair a second sans; do not add a display serif. Contrast is weight and size.
|
||||
|
||||
**The Uppercase-Is-Earned Rule.** Tracked uppercase lives on badges/tags exclusively. It is forbidden as a section eyebrow — that is a named anti-reference.
|
||||
|
||||
## 4. Elevation
|
||||
|
||||
A hybrid: soft tonal shadows give resting surfaces gentle separation from the background, while structural elevation is reserved for elements that genuinely float — modals, dropdowns, the sticky save bar. On top of that, interactive surfaces lift on hover (a 1–2px translate plus a stronger shadow). Depth is present and purposeful, never heavy.
|
||||
|
||||
### Shadow Vocabulary
|
||||
- **shadow-sm** (`0 2px 8px rgba(0,0,0,0.1)`): Resting cards, inputs, low panels. The default ambient layer.
|
||||
- **shadow-md** (`0 4px 12px rgba(0,0,0,0.15)`): Hover state for cards and buttons; raised toolbars.
|
||||
- **shadow-lg** (`0 12px 32px rgba(73,118,113,0.2)`): Structural float — modals, dropdowns, popovers, the save bar. Tinted with the brand hue.
|
||||
|
||||
### Named Rules
|
||||
**The Lift-on-Intent Rule.** Resting surfaces carry at most `shadow-sm`. `shadow-md` is a response to hover/focus; `shadow-lg` means the element floats above the page. Never use `shadow-lg` as decoration on a static card.
|
||||
|
||||
## 5. Components
|
||||
|
||||
### Buttons
|
||||
- **Shape:** Gently curved (12px radius, `{rounded.md}`); editor action buttons use 10px (`{rounded.field}`).
|
||||
- **Primary:** Muted Pine fill (#497671), white text, padding `0.625rem 1rem`, weight 600–700. Tactile and confident.
|
||||
- **Hover / Focus:** Background deepens to #3d635f, `translateY(-1px)` lift with `shadow-sm`; focus-visible shows a 2px Muted Pine outline offset 2px. `:active` returns to `translateY(0)`.
|
||||
- **Secondary:** Sage Grey (#a1b4b5) fill, white text; hover #8da3a4.
|
||||
- **Ghost:** Transparent, Deep Pine Ink text, Divider Grey border; hover fills `rgba(73,118,113,0.08)` and border shifts to Muted Pine.
|
||||
- **Disabled:** `opacity: 0.6`, no lift, no shadow, `cursor: not-allowed`.
|
||||
|
||||
### Cards / Containers
|
||||
- **Corner Style:** 12px (`{rounded.md}`).
|
||||
- **Background:** White (#ffffff) on Soft Grey (#f5f5f5) page.
|
||||
- **Border:** 1px Divider Grey (#d3dad9).
|
||||
- **Shadow Strategy:** `shadow-sm` at rest → `shadow-md` on hover with `translateY(-2px)` (product cards add a subtle `scale(1.01)`). See Elevation.
|
||||
- **Internal Padding:** 16px (`{spacing.md}`).
|
||||
- **Nested cards:** Editor sub-cards use `#fbfcfc` fill with the same 12px radius and 1px border.
|
||||
|
||||
### Inputs / Fields
|
||||
- **Style:** White fill, 1px Divider Grey border, 10px radius (`{rounded.field}`), padding `10px 12px`, inherits body font.
|
||||
- **Focus:** 2px Muted Pine focus-visible outline, offset 2px (global rule).
|
||||
- **Field description:** 12px, Muted Pine Grey (#667a77), sits under the label at weight 400.
|
||||
- **Error:** Error text #991b1b; color input controls get a 44px min-height touch target.
|
||||
|
||||
### Navigation
|
||||
- Neutral chrome, DM Sans, weight 600 for active items. Default text is Deep Pine Ink; active/hover carries Muted Pine. Header uses a low-tint `--bg-header` wash (brand hue at ~10% alpha). Mobile collapses to a menu; `body.platform-menu-open` locks scroll.
|
||||
|
||||
### Badges & Tags (signature)
|
||||
- **Badge:** Uppercase Label type (0.7rem, 600, 0.4px tracking), white text, 8px radius, `2px 8px` padding, solid semantic fills (new #4caf50, sale #f44336, hot #ff5722, limited #ff9800, bestseller #2196f3, featured #607d8b). Absolutely-positioned overlay top-left on product media.
|
||||
- **Tag:** Pill (12px radius), Muted Pine text on `rgba(73,118,113,0.08)` fill with a faint brand border. Low-emphasis metadata.
|
||||
|
||||
### Save Bar (signature)
|
||||
- Sticky, structurally elevated (`shadow-lg`), always states current state (unsaved changes / saving / published). The clearest expression of the Operator's Workbench: the operator always knows where the work stands.
|
||||
|
||||
## 6. Do's and Don'ts
|
||||
|
||||
### Do:
|
||||
- **Do** consume theme CSS custom properties (`--primary-color`, `--text-primary`, `--border-color`) — never hardcode hex in a component (ADR-008).
|
||||
- **Do** keep platform chrome neutral so tenant themes lead the storefront (The Quiet Chrome Rule).
|
||||
- **Do** carry hierarchy with DM Sans weight and size; one family only.
|
||||
- **Do** keep resting surfaces on `shadow-sm`; reserve `shadow-lg` for genuinely floating elements.
|
||||
- **Do** make state unambiguous — draft vs published, saved vs unsaved, safe vs destructive — in every tool surface.
|
||||
- **Do** give every hover/transform a `prefers-reduced-motion: reduce` fallback (handled globally in `styles.scss`).
|
||||
- **Do** hold 4.5:1 body-text contrast across every tenant theme, not just Dexar.
|
||||
|
||||
### Don't:
|
||||
- **Don't** ship dated enterprise admin: no cluttered gray dashboards, tiny dense tables, or 2010-era Bootstrap backoffice.
|
||||
- **Don't** ship generic AI-SaaS template: no cream/violet gradient landings, hero-metric card rows, tracked-uppercase eyebrows on every section, or identical icon-heading-text card grids.
|
||||
- **Don't** ship consumer-toy UI: no bubbly rounded-everything, mascots, candy colors, or gamified surfaces.
|
||||
- **Don't** use tracked uppercase anywhere except badges/tags (The Uppercase-Is-Earned Rule).
|
||||
- **Don't** exceed ~2.75rem on display headings — the workbench does not shout.
|
||||
- **Don't** add a second type family or a display serif.
|
||||
- **Don't** let platform-branded color fight a tenant's palette.
|
||||
223
GAPS-AND-IMPROVEMENTS.md
Normal file
223
GAPS-AND-IMPROVEMENTS.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Gaps & Improvements
|
||||
|
||||
Findings only — nothing in this document has been fixed as part of writing it. Sourced from re-verifying prior audits against current source, plus a fresh automated review pass across the storefront (website) and backoffice (admin). Organized by the lens each finding matters most to; several findings matter to more than one role and are cross-referenced rather than duplicated.
|
||||
|
||||
---
|
||||
|
||||
## As a Customer / End User
|
||||
|
||||
1. **FIXED (verified 2026-08-17).** ~~Ed25519 admin-auth "session expired"/"invalid signature" screens were dead UI~~ — `toAuthErrorShape()` now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. See `BACKEND-API-REFERENCE.md` §5.
|
||||
2. **FIXED (2026-08-17).** ~~Dark mode selector did nothing~~ — structural dark overrides (bg/text/border/shadow) now wired for all three tenant themes under `[data-theme-mode="dark"]`. Brand colors intentionally unchanged pending a theme-owner-approved dark palette.
|
||||
3. **FIXED (verified 2026-08-17).** ~~"Site Layout" selector had no effect~~ — `SectionEngineService.resolveLayoutType()` now falls back to `bootstrap.layout.type` when a page has no layout of its own.
|
||||
4. **Not a code gap (re-verified 2026-08-17), a content gap.** The mechanism is already fully generic: `features/project-editor/sections/footer-section.component.ts`'s Footer Builder lets an admin create any static page via the CMS and link it into a footer column by `pageKey`, resolved by `FooterResolverService`. "Contacts" just has no authored static page yet on whichever tenant's bootstrap this was checked against — that's a per-tenant content task, not a frontend fix.
|
||||
5. **FIXED (verified 2026-08-17).** ~~Product pages got no per-product SEO~~ — `SeoService.setItemMeta(item)` is called from `product-details-container.component.ts`.
|
||||
6. **FIXED (verified 2026-08-17).** ~~`og:locale` was hardcoded to `ru_RU`~~ — reads `languageService.currentLanguage()` via `OG_LOCALE_MAP` at both call sites.
|
||||
7. **FIXED (verified 2026-08-17) on JSON-LD; sitemap remains backend-only work.** ~~No structured data (JSON-LD) exists anywhere~~ — `SeoService.setJsonLd()` injects a real `<script type="application/ld+json">` for `Product` (per-item, via `setItemMeta()`) and `Organization` (site default, via `resetToDefaults()`). No JSON-LD exists yet for `BreadcrumbList` or `ItemList`/category pages — smaller net-new addition if wanted. Sitemap generation is still backend-only, unchanged.
|
||||
8. **FIXED (verified 2026-08-17).** ~~Checkout's payment-description fallback was a hardcoded Russian string~~ — `getPaymentDescription()` (`pages/cart/cart.component.ts:613`) already tries `branding.brandName`, then hostname, and only falls to `i18n.t('cart.paymentDescriptionFallback')` last, translated in all 3 languages (`i18n/{en,ru,hy}.ts`).
|
||||
9. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ — `--border-color` and `--success`/`--warning`/`--error`/`--info-color` darkened, hue-preserving, in all three theme files to clear 3:1 (border, non-text) and 4.5:1 (status colors, plain text). See [Accessibility](#as-accessibility-reviewer).
|
||||
10. **FIXED (verified 2026-08-17).** ~~`stars.component.scss:10` used a literal hex color~~ — now uses `var(--border-color)`.
|
||||
11. **No multi-vendor cart handling exists.** Checkout is one inline flow producing exactly one order from one payment popup; a cart with items from multiple sellers has no defined behavior (relevant the moment Seller Management ships beyond its current disabled-by-default placeholder).
|
||||
|
||||
---
|
||||
|
||||
## As Product Owner / Business
|
||||
|
||||
1. **Backend completion is ~10%.** Only Categories has a real HTTP implementation on the admin side; every other domain (Orders, Products, Users, Transactions, Monitoring, Moderation, Analytics, Customers) runs entirely on mock data today. The frontend is feature-complete against that mock data; production readiness is blocked entirely on backend work, not frontend polish.
|
||||
2. **The admin role model is decorative.** `AdminRole`/permissions exist in code, but **nothing gates any button, page, or action on them anywhere in the app.** Anyone who passes admin authentication has full access regardless of their assigned role. This is a real authorization gap, not a display nicety, and should be scoped before any real admin backend goes live with multiple operators.
|
||||
3. **Payment options are limited to QR and card via one custom flow** — no additional providers (wallets, buy-now-pay-later) are wired or planned; needs a business decision on which providers, if any, before integration work starts.
|
||||
4. **Advanced analytics (traffic, funnels, heatmaps) has no data source at all** — not a missing endpoint, a missing tracking pipeline. Flagged as the single largest ("XL") remaining backend effort, deliberately last in the build order because it depends on every other commerce domain being real first.
|
||||
5. **Seller Management has eight cross-linked documents for a capability that is disabled by default and has zero backend bytes.** Real risk if it proceeds: at least three of those documents independently restate the same undecided "Unified vs. Split Orders" question — a decision change means updating multiple documents in sync, not one. Worth a consolidation pass before backend implementation starts.
|
||||
6. **Two competing "seller" type shapes exist with no conversion between them** (`SellerConfig` in bootstrap models vs. `Seller`/`SellerBranding` in the domain layer) — self-flagged during Seller Management design work, restated here as unresolved. Recommend resolving (pick one, or document a mapping) before real backend work on that capability begins.
|
||||
7. **No reusable feature-flag/capability-guard service exists**, despite one being promised by an existing ADR. The one current consumer of `sellerManagement.enabled` hand-rolls the check inline; every future flag will either duplicate that pattern or need the promised service built retroactively under time pressure.
|
||||
8. **The Seller Management "enabled" code path has never been manually exercised**, even once — every verification claim about it was tested with the flag at its real-world value, `false`. Low risk today (nothing renders differently yet), but worth a fixture-based test the first time any enabled-state UI is actually built.
|
||||
9. **Two large lazy-loaded bundle chunks remain unaddressed**: `project-editor` (~1.0 MB) and `catalog-container` (~330–375 kB). No mechanical split has been found; needs a dedicated profiling pass, ideally under real backend latency rather than instant mock responses.
|
||||
|
||||
---
|
||||
|
||||
## As QA / Test Engineering
|
||||
|
||||
1. **Automated test coverage is thin relative to the stated 80% target.** 11 spec files exist repo-wide (up from 5 before this cycle's test-foundation sprint); measured baseline is ~32% statements, ~19% branches, ~22% functions, ~33% lines. This is an honest foundation, not a coverage floor — no CI gate is set on it yet, deliberately, until a real floor number can be justified.
|
||||
2. **Zero E2E tests exist anywhere in the repo.** No Playwright/Cypress/equivalent config found. Critical flows (storefront checkout, admin CRUD, builder draft→publish→live) have no automated regression coverage beyond the unit/facade specs added this cycle.
|
||||
3. **Several "verified live" claims in prior audits were actually code-inspection only**, not real authenticated click-throughs — consistently because `/edit`, `/edit/:section`, and `/backoffice` require Telegram admin login, which cannot be completed in the automated environment those passes ran in. Worth flagging to a human tester before trusting those UI claims as fully proven: the manifest-aware layout picker (Sprint E), and multiple backoffice-auth-gated wording checks (Monitoring, Reports) among them.
|
||||
4. **No real screen-reader software pass (NVDA/VoiceOver) has ever been performed anywhere in the app** — every accessibility claim in every audit to date is based on automated accessibility-tree inspection only (`role`, `aria-*` attribute presence), never an actual screen-reader session. This is a repo-wide gap, not specific to one page.
|
||||
5. **A reactive-signal staleness bug was found and fixed in Seller Management's enabled-flag read** (it read the bootstrap snapshot once at construction instead of reactively) — worth a general regression-test pattern for any future flag/config read that should track `bootstrapRevision()`, since this bug class is easy to reintroduce and was invisible until specifically looked for.
|
||||
6. **No facade-level tests exist yet for cart/checkout, moderation, or most admin domains** (Orders, Products, Users, Transactions, Monitoring were explicitly scoped out of this cycle's test-foundation sprint to stay within its time budget) — these are exactly the domains about to get real backends, so they carry the most regression risk with the least current coverage.
|
||||
|
||||
---
|
||||
|
||||
## As Backend / API Engineer
|
||||
|
||||
See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract. Structural gaps worth flagging here specifically:
|
||||
|
||||
1. **Only 2 of 11 admin gateway domains (Categories, Dashboard-metrics) have a DI-token seam.** The other 9 — Orders, Products, Users, Transactions, Monitoring, Moderation, plus derived Customers/Analytics — inject their mock gateway class directly. A token has to be added to each before any real backend can be bound, independent of how easy that domain's actual endpoint is to build.
|
||||
2. **FIXED (verified 2026-08-17).** ~~`AdminRole` was defined twice with unrelated shapes~~ — only one `AdminRole` export exists (`core/auth/models/permission.model.ts`); the Users-page shape is `AdminUserRoleRecord` with a disambiguating comment.
|
||||
3. **Two unrelated `Category` types exist**, both fed by the same `/category` response, both still in active use.
|
||||
4. **Worse than previously stated (re-verified 2026-08-17): three overlapping `SearchState`-shaped types, not two.** `core/search/models/search.model.ts` is already a clean re-export shim (fixed), but `core/search/models/search-state.model.ts` is a genuine second copy consumed by `catalog-container.component.ts`, and `features/search/facade/search.facade.ts` additionally defines its own private `LegacySearchState` interface with the same fields again. Reconciling all three touches the highest-traffic storefront surface (catalog rendering) — needs its own careful pass with full consumer tracing, not a quick rename.
|
||||
5. **The error envelope is entirely a proposal** — no interceptor in the app inspects error response bodies today; every error reaction happens at the raw HTTP-status level. Adopting an envelope is a net-new build for both sides, not a preservation of existing behavior.
|
||||
6. **429 (rate limiting) has zero client-side handling anywhere** — no interceptor, facade, or component references it. If the backend rate-limits, today's frontend has no graceful path for that response.
|
||||
7. **No API versioning scheme has been decided** — no version segment, no version header, anywhere in the client.
|
||||
8. **Centralized error-handling scaffolding exists but was never built.** `src/app/core/error-handling/`, `src/app/core/guards/`, and `src/app/core/interceptors/` each contain only a `.gitkeep` file — someone planned a shared error-handling layer, and every caller still handles failures ad hoc at the call site instead. Worth building once real backends start returning the error envelope in [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), rather than adding another one-off handler per facade.
|
||||
9. **Admin Reports and Seller Management pages have zero data wiring of any kind** — not even a mock gateway call. Reports reuses `AdminAnalyticsFacade` (itself mock-derived) for its numbers; Seller Management is a static placeholder page with no `HttpClient` reference anywhere. Neither is currently a "swap the gateway" job — Reports inherits whatever Analytics becomes, Seller Management has no data layer to swap yet.
|
||||
10. **FIXED (verified 2026-08-17).** ~~`PRODUCT_DATA_PROVIDER`/`CATEGORY_REPOSITORY` had a dead mock branch~~ — both tokens' factories now resolve directly to the real API implementation with the dead switch removed, documented inline as intentional.
|
||||
|
||||
---
|
||||
|
||||
## As Accessibility Reviewer
|
||||
|
||||
1. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ — see [Product Owner item 9 above](#as-product-owner--business). Applied a hue-preserving darkening of the failing tokens rather than a redesign; a distinct dark-mode-specific status palette (introduced alongside dark mode this session) has not been separately contrast-checked and remains open.
|
||||
2. **No screen-reader software testing has ever been performed on this codebase** — every existing accessibility verification (including in this review) is automated accessibility-tree inspection, never a real NVDA/VoiceOver session. Recommend at least one manual pass on the highest-traffic flows (checkout, product page, admin login) before treating any part of the app as accessibility-verified end to end.
|
||||
3. **Known past pattern worth re-checking elsewhere:** a raw `<textarea>` (no dedicated shared textarea component exists in the codebase) previously shipped without its `aria-label`/label association wired correctly in one place (Seller Management's Message field, since fixed). Any other raw `<textarea>` usage in the app should be checked for the same gap, since the shared `app-input` component handles this automatically but plain textareas do not.
|
||||
|
||||
---
|
||||
|
||||
## As Engineering / Tech Debt
|
||||
|
||||
1. **`navigation.header`** (header top-nav list) is editable in the builder but has zero runtime consumer — the header's actual menu comes from a different source entirely. Needs a product decision on positioning/behavior before it's real feature work, not a wiring fix.
|
||||
2. **`catalog.navigationMode`** renders an intentional placeholder — confirmed not a bug, but the alternate nav UIs it implies (mega-menu, top-carousel, left-nav) don't exist yet if ever wanted.
|
||||
3. **Angular 22 upgrade is researched but not started** (~2–3.5 days estimated, needs a dependency fix and Node version bump first). Explicitly recommended as its own dedicated session, never bundled with feature work.
|
||||
4. **`MarketplaceRef` and `TenantConfig` both represent "a marketplace" from two different vantage points** — a deliberate, documented distinction today, but worth consolidating if a third marketplace-shaped type is ever proposed.
|
||||
5. **FIXED (verified 2026-08-17).** ~~`sellerId` fields were typed as bare `string`~~ — `core/sellers/models/seller-scope.model.ts` and all other sellers-domain usages type it `UUID`.
|
||||
6. **No shared breadcrumb component exists anywhere** — the only breadcrumb logic in the entire storefront is one local signal inside the catalog container, duplicated conceptually wherever a future breadcrumb might be needed.
|
||||
7. **Bootstrap `apiEndpoints.{website,builder,backoffice}` are empty objects in the mock today** — meaning no builder or backoffice CRUD path exists as a literal anywhere in the client. Any concrete path documented for those domains is a proposal until this is populated.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting / process
|
||||
|
||||
- **Documentation was spread across 60+ overlapping markdown files** at the time of this review (status trackers, sprint plans, multiple overlapping backend specs, several audit reports referencing each other) — consolidated as part of this same pass into this file, [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), and whatever the team chooses to keep going forward. Recommend a lighter-weight doc set than before: one living gaps list (this file), one backend contract, source-of-truth code — not a new pile of point-in-time sprint reports.
|
||||
- **A running list of "Requires backend decision" items with recommended defaults already exists** inside `BACKEND-API-REFERENCE.md` and the source material behind it — treat those as the first thing to walk through with the client/backend team, since most already have a suggested default and don't need a meeting, only a sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Automated code review — website (storefront)
|
||||
|
||||
*Findings from a fresh source-level pass over `src/app/pages`, `src/app/features/website`, `src/app/features/search`, `src/app/widgets`, and the project-editor/builder. Each item includes a file:line reference and the role it matters most to.*
|
||||
|
||||
### Cart / Checkout
|
||||
|
||||
- `[Product Owner]` No dedicated checkout feature exists — `src/app/features/website/checkout/` and `src/app/features/website/cart/` are empty `.gitkeep` placeholders; the entire cart/payment flow lives in the legacy `src/app/pages/cart/`.
|
||||
- `[Engineering]` The post-payment email/phone capture flow (`submitEmail`, `onEmailInput`, `onPhoneInput`, `validateEmail`, `validatePhone`) is dead code — the template has no matching `<input>` anywhere, so these methods are never invoked (`src/app/pages/cart/cart.component.ts:507-729`).
|
||||
- `[Product Owner]` Because that form is unreachable, `recordOrder()` always submits the backoffice order with an empty `email`/`phone` for every purchase (`src/app/pages/cart/cart.component.ts:418-441`).
|
||||
- `[Engineering]` `autoSubmitPurchase()` schedules navigation to home via `setTimeout(…, 0)` unconditionally at the top of the method, before checking for a Telegram user ID or waiting on the `submitPurchaseEmail` call — navigation fires regardless of submission success or failure (`cart.component.ts:443-454`).
|
||||
- `[User]` When no Telegram user ID is available, `autoSubmitPurchase()` only logs to console and returns silently — no toast/notification, even as the popup is already closing (`cart.component.ts:450-454`).
|
||||
- `[Engineering / Security]` Payment `currency` is hardcoded to `'RUB'` in both `createPayment()` and `recordOrder()`, ignoring `LanguageService.currentCurrency()` — the total shown to the user can diverge from the currency actually sent to the payment gateway (`cart.component.ts:257,436`).
|
||||
- `[Security]` `amount` and per-item prices sent to `POST /cart` are computed entirely client-side from cart data in localStorage/Telegram CloudStorage, with no server round-trip to re-verify live prices before payment creation — a tampered local cart could request payment at an incorrect amount unless the backend independently revalidates (`cart.component.ts:253-266`, `services/cart.service.ts:133-190`).
|
||||
- `[QA]` `copyPaymentLink()` failure path only does `console.error` — no visible feedback that "copy link" failed (`cart.component.ts:495-505`).
|
||||
- `[Engineering]` Hardcoded Russian fallback string `'Покупка на Маркетплейсе'` used as the QR payment description when no brand name/hostname resolves — bypasses i18n entirely (`cart.component.ts:592-604`). See also [Customer item 8](#as-a-customer--end-user).
|
||||
- `[QA]` Cart quantity increases (`increaseQuantity`, `CartService.updateQuantity`/`addItem`) never validate against the item's available stock — a user can raise a cart line past what's in stock with no cap or warning (`cart.component.ts:119-121`, `cart.service.ts:206-256`).
|
||||
- `[Accessibility]` Swipe-to-reveal-delete on mobile cart rows is touch-only; a fallback always-visible delete button exists, but nothing makes the swipe *state itself* keyboard-reachable (`cart.component.ts:140-163`).
|
||||
- `[Product Owner]` Terms-of-service links (public offer, return policy, guarantee, privacy policy) are plain placeholder text on the cart page, explicitly flagged in-code as needing real backend-configured links (`cart.component.html:147-152`).
|
||||
|
||||
### Auth
|
||||
|
||||
- `[Security]` The customer web session id is stored in a plain, non-`HttpOnly` cookie set via `document.cookie` — readable by any script, exfiltratable via XSS (`services/auth.service.ts:173-180`).
|
||||
|
||||
### Search
|
||||
|
||||
- `[i18n]` `SearchFacade.popularSearches` is a hardcoded English list ("Smartphones", "Sneakers", …) never routed through translation — renders in English regardless of active locale (`features/search/facade/search.facade.ts:58-91`).
|
||||
- `[Product Owner]` `SearchTrendingService.loadTrending()` is a stub that always returns `null` ("Endpoint not available yet") — trending searches are non-functional end to end, and it overwrites the (already-hardcoded) fallback popular list with an empty one every time (`features/search/services/search-trending.service.ts:7-10`).
|
||||
|
||||
### Catalog
|
||||
|
||||
- `[Performance]` Catalog fetches at most 200 products per category in one call, then filters/sorts/paginates entirely client-side over that fixed batch — categories with more than 200 products silently truncate with no indication, and every filter/sort change re-processes the whole in-memory array instead of re-querying (`features/website/catalog/containers/catalog-container.component.ts:160,675-699`).
|
||||
- `[Engineering / tech-debt]` `restoreContinueBrowsing()` is fully implemented (restores search/sort/layout/scroll) but never called anywhere — "continue browsing" state is saved on every interaction but never actually restored (`catalog-container.component.ts:901-929`).
|
||||
- `[Engineering]` Non-numeric category route tokens are resolved by fetching the entire category tree and slugifying titles client-side — fragile against duplicate or renamed category titles, and loads the full tree just to resolve one slug (`catalog-container.component.ts:943-959`).
|
||||
- `[Performance]` Price-range filter inputs trigger a full catalog recompute + URL sync on every keystroke, with no debounce (unlike the search box) (`features/website/catalog/components/filters-panel/filters-panel.component.ts:80-94`).
|
||||
- `[QA]` Range filter min/max inputs have no validation preventing `min > max` — an inverted range silently yields zero results with no explicit error state (`filters-panel.component.ts:80-94`, `.html:56-76`).
|
||||
|
||||
### Product details
|
||||
|
||||
- `[User]` The "Notify Me" button (shown when out of stock) is wired to `toggleWishlist()` — it does not create any real back-in-stock subscription, just relabels the wishlist button (`features/website/product/containers/product-details-container.component.ts:356-358`, `product-actions.component.html:22-24`).
|
||||
- `[Engineering]` "Buy Now" calls `addToCart()` (which resolves item data via an async, subscribed call inside `CartService.addItem`) and immediately navigates to `/cart` without awaiting completion — the cart page can render before the item has actually been added (`product-details-container.component.ts:306-320`, `cart.service.ts:206-243`).
|
||||
|
||||
### Product cards / search results
|
||||
|
||||
- `[Engineering / tech-debt]` The "Quick View" button on every search-results product card emits `quickViewPlaceholder`, but no parent component anywhere binds that output — clicking it is a dead end (`components/product-card/product-card.component.html:23-25`, `.ts:83-87`, `catalog/components/search-results/search-results.component.html:18-33`).
|
||||
|
||||
### Wishlist / Compare
|
||||
|
||||
- `[i18n]` The Compare table renders `product.name`/description fields/color/size straight off the raw product object instead of through `getTranslatedField()` — names and specs on the Compare page always show the source language regardless of active locale (`features/website/user-experience/compare/components/compare-table.component.ts:42-58`).
|
||||
- `[Product Owner]` Wishlist, compare, recently-viewed, and saved searches are all localStorage-only via `USER_EXPERIENCE_REPOSITORY`, with no server sync to the authenticated Telegram session — data is lost on device change or storage clear despite the app having real login (`facades/platform/user-experience.facade.ts:1-137`).
|
||||
|
||||
### Widgets / Page builder
|
||||
|
||||
- `[Engineering]` `DataSourceResolverService.resolve()` has no `catchError`/fallback on any branch — an API failure while loading a widget's data propagates as an unhandled Observable error (`widgets/resolvers/data-source-resolver.service.ts:20-52`).
|
||||
- `[User]` Because of the above, a single failing widget just silently fails to render (stays blank) — no retry, error message, or loading skeleton anywhere in the chain (`layouts/containers/dynamic-page-layout.component.ts:55-61`, `dynamic-renderer/widget-host/widget-host.service.ts:23-58`).
|
||||
- `[Engineering / tech-debt]` `'html'`, `'banner'`, and `'partners'` widget types have full data-resolution logic written but no entry in `APPROVED_WIDGET_COMPONENTS` — configuring one of these renders `UnknownWidgetComponent` on the live storefront instead of real content (`widgets/registry/widget-registry.bootstrap.service.ts:9-17`, `data-source-resolver.service.ts:218-257`).
|
||||
- `[Accessibility]` The hero widget carousel auto-advances every 5s whenever `data.autoplay` is set, with no pause/stop control exposed to the user — only the CSS entrance animation respects `prefers-reduced-motion`, the autoplay timer itself does not (WCAG 2.2.2 risk) (`widgets/ui/hero-widget.component.ts:292-301`).
|
||||
|
||||
### Static / CMS pages
|
||||
|
||||
- `[User]` `loadByKey`/`loadByPath` subscribe with only a `next` handler, no `error` callback — if bootstrap loading fails, `loading` stays `true` forever and the page shows an infinite spinner with no error state (`pages/static-page/static-page.component.ts:76-92`).
|
||||
|
||||
### i18n / Performance
|
||||
|
||||
- `[Performance]` `TranslatePipe` is declared `pure: false`, so every `| translate` binding (hundreds across templates — 40+ on the cart page alone) re-evaluates on every change-detection cycle instead of only when the language changes — a real cost at scale even under `OnPush` components (`i18n/translate.pipe.ts:4-14`).
|
||||
|
||||
## Automated code review — backoffice (admin)
|
||||
|
||||
*Findings from a fresh source-level pass over every `src/app/features/admin/*` module and `src/app/features/backoffice`. Each item includes a file:line reference and the role it matters most to.*
|
||||
|
||||
### Security / Authorization
|
||||
|
||||
- `[Security]` `adminAuthGuard` only checks `isAuthenticated()` — there is no role/permission check anywhere in the codebase (zero matches for a permission check project-wide). Any authenticated admin, including a seeded "viewer" role, can perform every destructive action: delete products/orders, change any user's role, refund orders (`core/admin-auth/admin-auth.guard.ts:6-15`). Same root cause as [Product Owner item 2](#as-product-owner--business).
|
||||
- `[Security]` `AdminRole.permissions` arrays (owner/admin/editor/viewer) exist only as display labels — nothing gates a button, route, or action on them (`features/admin/users/services/admin-users-local.gateway.ts:7-12`, `admin-users-page.component.ts:59-64`).
|
||||
- `[Security]` Nothing prevents suspending or demoting the last remaining `owner`-role user — `setStatus`/`setRole` apply unconditionally (`features/admin/users/facade/admin-users.facade.ts:35-41`).
|
||||
- `[Security]` Category slug-uniqueness check **fails open**: on API error, `isSlugTaken` swallows the error and returns `false` ("not taken"), letting a duplicate/conflicting slug through silently instead of blocking submission (`features/admin/categories/services/admin-categories-api.gateway.ts:56-65`).
|
||||
|
||||
### Audit / Traceability
|
||||
|
||||
- `[Security]` Audit/timeline entries hardcode `actor: 'admin'` as a literal string instead of the real authenticated admin's identity — the "who did this" record is meaningless the moment more than one admin uses the system. Affects role/status changes, transaction retry/fraud-flag, and review moderation (`features/admin/users/services/admin-users-local.gateway.ts:93-94`; `transactions/services/admin-transactions-local.gateway.ts:41,50`; `moderation/services/admin-moderation-local.gateway.ts:64,74`).
|
||||
- `[Security]` Orders have no actor/audit field at all — `AdminOrderTimelineEntry` has no `actor` property, so cancel/refund/status-change history records *what* changed but never *who* changed it, for the most financially sensitive module in the app (`features/admin/orders/models/admin-order.model.ts:32-36`).
|
||||
|
||||
### Destructive actions with missing/inconsistent confirmation
|
||||
|
||||
- `[Admin-operator]` Product delete (single row and bulk) removes the product permanently with **zero confirmation of any kind**, not even a native `confirm()` (`features/admin/products/components/admin-products-list.component.html:134,161` → `admin-products.facade.ts:316-317`; bulk: `admin-products-list-page.component.ts:36` → `facade.ts:175-178`).
|
||||
- `[Admin-operator]` Order bulk-delete removes selected orders permanently with zero confirmation — destroys financial records in one click (`features/admin/orders/pages/admin-orders-list-page.component.html:48` → `admin-orders.facade.ts:134-140`).
|
||||
- `[Admin-operator]` The review bulk-action button is labeled **"archive"** (implying reversible) but actually calls `deleteReview`, which permanently splices the review out — no confirmation dialog (`moderation/pages/admin-reviews-list-page.component.html:56` → `admin-moderation.facade.ts:148-154` → `admin-moderation-local.gateway.ts:94-97`).
|
||||
- `[Admin-operator]` Category bulk-delete has no confirmation, inconsistent with the same module's single-delete flow, which does confirm (`categories/pages/admin-categories-list-page.component.ts:40` vs `76-84`).
|
||||
- `[QA]` The order-detail "Change status" dropdown bypasses the confirm-gated Cancel/Refund buttons next to it — picking `cancelled`/`refunded` from the dropdown applies immediately with no confirmation (`orders/pages/admin-order-detail-page.component.html:49` vs `55-57`).
|
||||
- `[QA]` Terminal order statuses aren't enforced in the UI — the status dropdown stays active after an order reaches `cancelled`/`refunded`, so a terminal order can be silently moved back to any other status (`admin-order-detail-page.component.html:47-58`).
|
||||
- `[Accessibility / Engineering]` Confirmation UX is implemented three inconsistent ways across the app: native `window.confirm()`/`alert()` (Categories, Orders cancel/refund, Users suspend), a themed dialog component (Media Library only), and nothing at all (Products, Orders/Reviews bulk-delete) (`categories/pages/admin-categories-list-page.component.ts:78,81`; `orders/pages/admin-order-detail-page.component.ts:80,86`; `users/pages/admin-users-page.component.ts:43`; vs `features/backoffice/media/media-library-page.component.html:126-140`).
|
||||
|
||||
### Missing/incorrect loading, empty, error states
|
||||
|
||||
- `[QA]` Order-detail and Customer-detail pages collapse "loading," "not found," and "error" into one static "Loading…" string shown forever if the record isn't found or the request errors — `loadDetail` has no `error` handler (`orders/pages/admin-order-detail-page.component.html:89-91`, facade `admin-orders.facade.ts:98-100`; `customers/pages/admin-customer-detail-page.component.html:51-53`).
|
||||
- `[QA]` Products, Categories, Orders, Customers, and Transactions facades swallow load errors into an empty array with no distinct `error` signal — a genuine API failure renders as "No results found" rather than an error-with-retry state, unlike Users/Monitoring/Analytics, which do track error separately (`products/facade/admin-products.facade.ts:101-114`; `customers/facade/admin-customers.facade.ts:42-54`; `transactions/facade/admin-transactions.facade.ts:15-29`).
|
||||
- `[QA]` The Reports page never checks `facade.error()` even though `AdminAnalyticsFacade` exposes it — on load failure it silently renders 0%/0 stats instead of an error message (`admin/reports/pages/admin-reports-page.component.html:1-32`, facade `admin-analytics.facade.ts:41,77`).
|
||||
- `[Engineering]` Save/mutate calls across Products, Categories, and Users subscribe with only a `next` handler — a failed save/delete/role-change fails completely silently with no user-facing feedback (`products/facade/admin-products.facade.ts:305-314`; `categories/facade/admin-categories.facade.ts:314-332`; `users/facade/admin-users.facade.ts:35-50`).
|
||||
|
||||
### Fake/stubbed data presented as real
|
||||
|
||||
- `[Engineering / Product Owner]` Monitoring's security/audit events, queue depths, and webhook deliveries are entirely synthetic (seeded fake generators, hardcoded queue states, modulo-based fake webhook statuses) with no connection to any real backend, yet presented as a live security/audit surface (`monitoring/services/admin-monitoring-local.gateway.ts:14-92`).
|
||||
- `[Admin-operator]` The Monitoring page loads once on construction with no polling/auto-refresh and no manual refresh control (only a retry-on-error button) (`monitoring/pages/admin-monitoring-page.component.ts:39-41`; template `:79`).
|
||||
- `[Admin-operator]` The topbar global search input has no `(input)`/`(keyup.enter)` binding and no handler anywhere — it is entirely decorative (`shell/admin-layout.component.html:118`).
|
||||
- `[Admin-operator]` The notification bell always opens a panel showing a static "no notifications" message — no notification data source is wired up at all (`shell/admin-layout.component.html:141-157`).
|
||||
- `[Product Owner]` The Dashboard's "images without alt text" health check is hardcoded to `status: 'unknown'` with no code path that could ever resolve it — a permanent placeholder sitting alongside real, resolvable checks (`dashboard/facade/admin-dashboard.facade.ts:132`).
|
||||
- `[Product Owner]` "Request Refund" doesn't touch any payment processor — it only flips `payment.status` to `refund_requested`; actually completing the refund means separately picking "refunded" from the unrelated status dropdown, with no workflow linking the two (`orders/services/admin-orders-local.gateway.ts:43-50`).
|
||||
|
||||
### Incomplete modules
|
||||
|
||||
- `[Product Owner]` Settings (`/backoffice/settings`) is fully routed and linked in nav but contains exactly one control (density toggle to localStorage) — no store/payment/tax/shipping/notification settings exist despite the nav entry implying a general settings page.
|
||||
- `[Product Owner]` Seller Management's "Request Access" form submission is mocked with a bare `setTimeout` — no backend call exists, "Learn more" is static copy. Confirms [Product Owner item 5-8](#as-product-owner--business) are still accurate against current source.
|
||||
- `[Product Owner]` Customer-detail "Notes" card always shows a static "notes unavailable" message — no way to add customer notes at all, unlike Orders, which supports both customer-facing and internal notes (`customers/pages/admin-customer-detail-page.component.html:28-31`).
|
||||
- `[Product Owner]` "Reports" duplicates "Analytics" 1:1 — both consume the same facade and independently re-run the same nested aggregation — but Reports exposes only 3 CSV export buttons. Unclear differentiation between the two nav entries for an operator (`reports/pages/admin-reports-page.component.ts:15-20`).
|
||||
|
||||
### Missing validation
|
||||
|
||||
- `[QA / Product Owner]` The product create/edit form has **zero client-side validation** — no required-field checks (name, price, SKU), no min/max on price/discount/quantity. A completely empty or negative-priced product can be saved with no warning (`products/components/admin-product-form.component.ts`, entire file; save path `admin-products.facade.ts:305-314`).
|
||||
|
||||
### Performance
|
||||
|
||||
- `[Performance]` `AdminAnalyticsFacade.load()` chains four nested subscriptions (orders → products → categories → reviews) instead of `forkJoin`/`combineLatest`, with no cancellation of in-flight requests — rapidly toggling the date-range filter can let a stale response overwrite a newer one (`analytics/facade/admin-analytics.facade.ts:65-130`).
|
||||
- `[Performance]` Dashboard-stat computations across Orders, Customers, Transactions, and Analytics each independently re-fetch the entire order list with `pageSize: 100000` rather than sharing one cached read (`orders/facade/admin-orders.facade.ts:166-169`; `customers/facade/admin-customers.facade.ts:44,57`; `analytics/facade/admin-analytics.facade.ts:79,92`).
|
||||
|
||||
### Engineering / tech-debt
|
||||
|
||||
- `[Engineering]` `AdminCustomersFacade.buildCustomers()` treats `customerOrders[0]` as the customer's "latest" order with no local sort — correctness depends entirely on the orders gateway happening to already return descending-by-`createdAt` order; swapping in a differently-ordered real gateway would silently corrupt "last order" with no compiler or runtime signal (`customers/facade/admin-customers.facade.ts:24-39` vs `orders/services/admin-orders-local.gateway.ts:20`).
|
||||
- `[Engineering]` Bulk operations (Orders, Categories, Products, Moderation) fire N independent gateway calls in a loop with no `forkJoin`, no per-item error handling, and no aggregate loading indicator — one failed item in a batch gives the user no signal at all (`moderation/facade/admin-moderation.facade.ts:133-154`; `orders/facade/admin-orders.facade.ts:119-140`; `categories/facade/admin-categories.facade.ts:391-397`).
|
||||
|
||||
### Doc-drift confirmed against the Seller-Management audit
|
||||
|
||||
- `[Engineering]` `Seller-Management-Backoffice-Readiness-Audit.md` stated Settings "No route exists... Skipped, nothing to audit" — now stale: `/backoffice/settings` is a real routed page with no `comingSoon` flag (`shell/admin-nav.model.ts:48`, `app.routes.ts:266-274`).
|
||||
- `[Engineering]` Since that audit, `AdminOrder` and `AdminProduct` have both gained an explicit, unread `sellerId?: string` groundwork field — partially updates the audit's "no injection point" framing at the order level, though its core point (no per-item seller attribution on `AdminOrderItem`) remains accurate (`orders/models/admin-order.model.ts:54-59`; `products/models/admin-product.model.ts:115-120`).
|
||||
49
PRODUCT.md
49
PRODUCT.md
@@ -1,49 +0,0 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Primary users are tenant operators — merchants and admins who build and run their own marketplace through the Project Editor (builder) and the Admin/backoffice. They are task-focused power users: configuring theme, layout, navigation, pages, products, and static content, then publishing. Their context is repeated, deliberate work sessions where speed, clarity, and confidence that a change did what they expected matter more than delight.
|
||||
|
||||
Secondary users are end shoppers browsing a tenant storefront — catalog, product pages, cart, static pages. They arrive casually, judge fast, and are conversion-driven. Every storefront is themed per tenant, so shoppers should experience the tenant's identity, not the platform's.
|
||||
|
||||
Operators come first; shoppers second. The tool must be genuinely good to work in, and the storefront it produces must convert.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
A configuration-driven, multi-tenant marketplace platform. One Angular frontend serves unlimited tenants: identity, theme, navigation, page/section/widget composition, and static content all resolve at runtime from a per-tenant `bootstrap.json`, with the tenant chosen by request host. No tenant-specific code paths exist. A new marketplace is onboarded by domain plus config plus backend data — never by forking the frontend. Success is an operator standing up and running a complete, on-brand storefront end to end without writing code, and a shopper on that storefront never sensing the platform underneath.
|
||||
|
||||
## Positioning
|
||||
|
||||
Launch and run a full marketplace with no code: from one runtime config a tenant gets a brandable storefront, an admin backoffice, and a visual editor, onboarded by domain alone. The frontend renders entirely from bootstrap JSON, so tenant identity is fully configurable and the data backend can change without touching the app. The single claim every surface reinforces: everything you see is config, not custom code.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Precise, calm, trustworthy. The platform chrome behaves like commerce infrastructure: confident, low-drama, and out of the way. It states what happened plainly, makes destructive and publishing actions unambiguous, and never competes for attention with the tenant's own branding. Voice is direct and operator-literate, not salesy.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Not dated enterprise admin: no cluttered gray dashboards, tiny dense tables, or 2010-era Bootstrap backoffice. Not generic AI-SaaS template: no cream/violet gradient landings, hero-metric card rows, tracked-uppercase eyebrows on every section, or identical icon-heading-text card grids. Not consumer toy: no bubbly rounded-everything, mascots, candy colors, or gamified UI.
|
||||
|
||||
## Design Principles
|
||||
|
||||
Config, not custom — the UI's job is to make an entirely configuration-driven system feel direct and predictable; every editor control maps visibly to what it changes.
|
||||
|
||||
Quiet chrome, tenant identity leads — the platform's own shell stays neutral so per-tenant themes carry the storefront's character; the platform never imposes an identity over the tenant's.
|
||||
|
||||
Operator-first clarity — density, task speed, and unambiguous state (draft vs published, saved vs unsaved, destructive vs safe) win over decoration in the tooling surfaces.
|
||||
|
||||
Trust through precision — plain confirmation of what happened, honest empty/error states, and no surprises around publish, reset, or delete.
|
||||
|
||||
Practice what you preach — the editor and admin should feel as considered as the storefronts they produce; the tool is itself a demonstration of the platform's quality.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
WCAG 2.2 AA. Body text meets 4.5:1 contrast, all interactive flows are keyboard-navigable with visible focus states, and every animation has a `prefers-reduced-motion` alternative. Because storefront palettes are tenant-configurable, contrast must hold across themes, not just the default one.
|
||||
78
README.md
78
README.md
@@ -1,78 +0,0 @@
|
||||
# Marketplace Frontend
|
||||
|
||||
Angular 21 multi-tenant marketplace platform frontend. Standalone components, signals, no NgRx. One codebase serves unlimited tenants ("marketplaces") via a per-tenant `bootstrap.json` fetched at runtime — no tenant-specific code paths.
|
||||
|
||||
Three surfaces on this one codebase:
|
||||
- **Storefront** (`/`) — the public shopping site: catalog, product pages, cart, static/CMS pages.
|
||||
- **Builder / Project Editor** (`/edit/**`) — in-app editor that edits the tenant's `BootstrapConfig` (theme, nav, homepage sections, widgets, footer, languages, static pages).
|
||||
- **Backoffice / Admin** (`/:lang/backoffice/**`) — products, categories, orders, transactions, users, moderation, media, monitoring, analytics.
|
||||
|
||||
## Architecture
|
||||
|
||||
`Component (container) → Facade → Domain Service → Repository/Provider (DI token, swappable mock↔API) → Mock | API`
|
||||
|
||||
Enforced by `npm run arch:check` (import boundaries + circular deps), not just convention. Full detail: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), governance ADRs at `docs/architecture/foundation/**`.
|
||||
|
||||
## Frontend status
|
||||
|
||||
**Release Candidate — feature-complete.** See [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) for the honest current-state breakdown (completion %, known limitations, readiness for demo/production/backend).
|
||||
|
||||
## Backend
|
||||
|
||||
**Not implemented yet — fully specified.** Every domain currently runs against an in-memory/mock gateway except Categories (the one domain wired to a real HTTP API). The complete contract a backend engineer needs — every endpoint, DTO, auth flow, error model, upload contract, and a step-by-step implementation checklist — lives in one canonical document:
|
||||
|
||||
**[`docs/BACKEND.md`](docs/BACKEND.md)**
|
||||
|
||||
## How to switch Mock ↔ API
|
||||
|
||||
Toggle `useMockData` in `src/environments/environment.ts` (or `environment.production.ts`). `RuntimeProviderStrategyService` (`src/app/core/providers/runtime-provider-strategy.service.ts`) reads this flag per-domain to decide whether a facade gets the mock or real gateway. On `localhost` with `useMockData: false`, some domains (bootstrap, categories) still fall back to mock automatically so local dev never silently hits a real backend by accident — see that service for the exact per-domain logic.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install # install dependencies
|
||||
npm start # local dev server
|
||||
npm run build # production build -> dist/dexarmarket/
|
||||
npm run arch:check # import-boundary + circular-dependency check
|
||||
```
|
||||
|
||||
## Folder structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/
|
||||
│ ├── components/ # Shared storefront components (header, footer, product-card, etc.)
|
||||
│ ├── core/ # Auth, admin-auth, config/tenant resolution, DI providers, interceptors
|
||||
│ ├── dynamic-renderer/ # Bootstrap JSON -> section/widget rendering pipeline (live homepage engine)
|
||||
│ ├── facades/ # Runtime, website, builder, and backoffice facades
|
||||
│ ├── features/ # Domain features: admin/*, project-editor, content-management, website/*
|
||||
│ ├── guards/ # Route guards (language, admin-auth, dirty-state, etc.)
|
||||
│ ├── i18n/ # Translation service, pipe, and locale packs (en/ru/hy)
|
||||
│ ├── pages/ # Top-level routed pages: home, cart, static-page
|
||||
│ ├── services/ # API, cart, auth, SEO, Telegram, and language services
|
||||
│ ├── shared/ # Shared UI primitives (button, dialog, confirm-dialog, table, etc.)
|
||||
│ └── widgets/ # Dynamic-renderer widget components
|
||||
├── assets/mock/ # Local mock configuration and catalog data
|
||||
├── environments/ # Development and production environment settings (incl. useMockData)
|
||||
└── styles/ # Shared global styles and themes
|
||||
```
|
||||
|
||||
## Documentation map
|
||||
|
||||
Full index: [`docs/PROJECT_INDEX.md`](docs/PROJECT_INDEX.md). Key entry points:
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) | Current completion status, honest limitations, demo/production readiness |
|
||||
| [`docs/BACKEND.md`](docs/BACKEND.md) | The one canonical backend spec — endpoints, DTOs, auth, security, errors, uploads, checklist |
|
||||
| [`docs/NEXT_PHASE.md`](docs/NEXT_PHASE.md) | Roadmap: backend integration → testing → performance → monitoring → v2 |
|
||||
| [`docs/TODO.md`](docs/TODO.md) | Release blockers only |
|
||||
| [`docs/KNOWN-ISSUES.md`](docs/KNOWN-ISSUES.md) | Real, reproducible, currently-open frontend bugs |
|
||||
| [`docs/PRODUCT_BACKLOG.md`](docs/PRODUCT_BACKLOG.md) | Items needing a client/business decision |
|
||||
| [`DESIGN.md`](DESIGN.md) | Visual design system |
|
||||
| [`PRODUCT.md`](PRODUCT.md) | Product positioning |
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentication and payment integrations are on their existing contracts — see `docs/BACKEND.md` for the auth/security contract a real backend must satisfy.
|
||||
- Client-facing content should avoid placeholder names, mock labels, and temporary routes.
|
||||
@@ -49,6 +49,10 @@
|
||||
{
|
||||
"replace": "src/app/interceptors/mock-data.interceptor.ts",
|
||||
"with": "src/app/interceptors/mock-data.interceptor.production.ts"
|
||||
},
|
||||
{
|
||||
"replace": "src/app/mock-gateway.providers.ts",
|
||||
"with": "src/app/mock-gateway.providers.production.ts"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
@@ -59,7 +63,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "700kB",
|
||||
"maximumError": "1.5MB"
|
||||
"maximumError": "1.1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Angular 22 Upgrade Plan (research only — not applied)
|
||||
|
||||
Feasibility assessment for upgrading from the current Angular 21.1.5 to Angular 22. **No upgrade was performed** — this is a plan, per mission instructions ("Do NOT upgrade automatically. Stop.").
|
||||
|
||||
## Current state (verified against `package.json` + npm registry, 2026-07-25)
|
||||
|
||||
| Package | Current | Latest available |
|
||||
|---|---|---|
|
||||
| `@angular/core` (+ animations/cdk/common/compiler/forms/platform-browser/router/service-worker) | 21.1.5 | 21.2.18 (latest 21.x) / 22.1.0-rc.0 (latest 22.x) |
|
||||
| `typescript` | ~5.9.3 | 6.0.3 stable |
|
||||
| `rxjs` | ~7.8.0 | compatible with both 21 and 22 |
|
||||
| `zone.js` | ~0.16.0 | compatible with 22 (`~0.15.0 \|\| ~0.16.0` required) |
|
||||
| `primeng` | ^21.0.3 | 22.0.0 stable exists |
|
||||
| `@lucide/angular` | ^1.25.0 | no upper Angular bound (`>=17.0.0`) — not a blocker |
|
||||
| Node.js (this environment) | v22.16.0 | Angular 22 CLI requires `^22.22.3 \|\| ^24.15.0 \|\| >=26.0.0` — **current Node does not satisfy this** |
|
||||
|
||||
Correction to the project's own status tracking: `docs/PROJECT_INDEX.md`/`CLAUDE.md` describe this as "Angular 18+" — the repo is actually already on **21.1.5**, one major behind the latest stable (22.1). This is a much smaller jump than "18→22" would imply.
|
||||
|
||||
## Verdict: upgrade is safe, with 2 concrete pre-requisites
|
||||
|
||||
Nothing found in this codebase blocks the jump on its own merits — the risk is entirely in the dependency chain, not the app code:
|
||||
|
||||
1. **`primeng@^21.0.3` peer-depends on `@angular/core@^21.0.7` only** — it does not accept Angular 22 today. **However**, this dependency is already dead code (`docs/KNOWN-ISSUES.md` item 12): its only consumer, `items-carousel`, was deleted during RC PERF-01, and its removal is already planned, just blocked on an unrelated `npm uninstall` failure (see #2). Once `primeng`/`primeicons` are actually removed from `package.json`, this blocker disappears entirely — no need to wait for/adopt `primeng@22`.
|
||||
2. **`barry-cache@^0.1.0` in `package.json` no longer resolves** (`ETARGET`) — confirmed via `npm view barry-cache`: the real published range is now `0.9.3` (20 versions total), and `^0.1.0` doesn't intersect anything currently on the registry. This is what's been silently blocking `npm install`/`npm uninstall` all cycle (referenced in `docs/PERFORMANCE_REPORT.md`, `docs/KNOWN-ISSUES.md` item 12). **This must be fixed first** — bump `barry-cache` to a current version — or `ng update` itself will fail the same way `npm uninstall primeng` already does.
|
||||
3. **Node.js**: this dev environment runs v22.16.0; Angular 22's CLI requires `^22.22.3 \| ^24.15.0 \| >=26.0.0`. A Node bump is required before `ng update` will even run, independent of the app.
|
||||
|
||||
Once those 3 are resolved, the app itself is well-positioned:
|
||||
- 100% standalone components already (no NgModules to migrate).
|
||||
- 190/191 components already `ChangeDetectionStrategy.OnPush` (per `docs/PERFORMANCE_REPORT.md`) — directly aligned with v22 making OnPush the default; this app barely changes behavior from that shift.
|
||||
- Heavy existing signals usage (facades are signal-based per `docs/ARCHITECTURE.md`/ADR-007) — aligned with where Angular is going (Signal Forms, `resource()`), no fighting the framework.
|
||||
- Zero usage found of the specific APIs v22 removes: `ComponentFactoryResolver`, `ComponentFactory`, `provideRoutes()`, `CanMatchFn` (grepped `src/app/**`, zero hits).
|
||||
- Zone-based (not zoneless) via `provideZoneChangeDetection({eventCoalescing: true})` in `app.config.ts` — this continues to work under v22, no forced zoneless migration needed to upgrade.
|
||||
|
||||
## Benefits
|
||||
|
||||
- Bug fixes and perf improvements shipped between 21.1 and 22.1 (6+ months of patches this repo isn't getting).
|
||||
- OnPush-by-default aligns with where this codebase already is — near-zero migration cost for that specific change, unlike a codebase still on default change detection.
|
||||
- Keeps pace with `primeng`/ecosystem packages that are already moving to v22-only releases (relevant once primeng is actually removed and no longer a constraint either way).
|
||||
- Closes the gap before the next major (v23) makes this a two-major jump instead of one.
|
||||
|
||||
## Risks / breaking changes relevant to this codebase
|
||||
|
||||
1. **Route parameter inheritance changes from `emptyOnly` to `always`.** No explicit `paramsInheritanceStrategy` override was found in `app.routes.ts` or `app.config.ts` — meaning this app is on the default, and the default is changing. **Concrete risk**: any component reading `ActivatedRoute.params`/`paramMap` that currently expects to NOT see a parent route's params (e.g. a child route under `/:lang/backoffice/:id/edit` reading only its own segment) could start receiving inherited params it didn't before. Needs a manual audit of nested routes with route params at each level — `src/app/app.routes.ts` has several (product detail, category, admin edit routes) — not just a blanket "run the test suite and hope."
|
||||
2. **TypeScript 6.0 minimum** — current is 5.9.3, a straightforward `npm install typescript@^6.0.3` bump, but TS 6 does include its own (separate) breaking changes to check independently of Angular (stricter inference in some cases) — budget a pass for TS compiler errors post-bump, not just Angular's.
|
||||
3. **`primeng`/`primeicons` removal must land first** (see Verdict #1) — sequencing matters: remove dead deps → fix `barry-cache` → bump Node → `ng update`, not the reverse.
|
||||
4. **No automated test suite beyond the default Jasmine/Karma scaffold** was confirmed running in this session (`docs/SPRINT-PLAN.md` Sprint 29 notes: "Translation validation / lint... No lint script exists") — meaning post-upgrade regression detection leans entirely on `tsc --noEmit` + `ng build` + manual verification, the same constraint every other pass this cycle has worked under. The route-params risk above in particular needs *manual* route-by-route verification, not just a green build, since it's a runtime behavior change a type-checker can't catch.
|
||||
|
||||
## Migration steps (sequenced)
|
||||
|
||||
1. **Unblock tooling**: bump `barry-cache` in `package.json` to a currently-published version (`0.9.3` or latest at execution time) — verify with `npm view barry-cache versions` first.
|
||||
2. **Remove dead `primeng`/`primeicons`** (already-planned, `docs/KNOWN-ISSUES.md` item 12) — now unblocked by step 1. Verify `npm run build` still green afterward (it was already confirmed code-dead in RC PERF-01, this just finishes the dependency removal).
|
||||
3. **Bump Node.js** in the dev/CI environment to satisfy `^22.22.3 | ^24.15.0 | >=26.0.0`.
|
||||
4. **Bump TypeScript** to `^6.0.3`, run `tsc --noEmit`, fix any TS-6-specific compiler errors before touching Angular.
|
||||
5. **Run `ng update @angular/core@22 @angular/cli@22`** (and `@angular/cdk@22` if still a dependency) — let the official schematic handle the mechanical parts.
|
||||
6. **Audit route-param inheritance** manually across every nested route with params in `app.routes.ts` (product detail, category, admin edit/detail routes) — the one behavior change with no automated safety net.
|
||||
7. **Full verification pass**: `tsc --noEmit`, `npm run build`, `npm run arch:check`, plus a live browser walkthrough of the same route list used in `docs/RELEASE_REPORT.md` (storefront/builder/backoffice) — this upgrade deserves the same rigor as that pass, not just a build check.
|
||||
8. **Commit, do not push** without explicit sign-off, same as every other pass this cycle.
|
||||
|
||||
## Estimated effort
|
||||
|
||||
- Steps 1-4 (unblock tooling, remove dead deps, Node/TS bump): **0.5-1 day** — mechanical, low risk, mostly already-planned work.
|
||||
- Step 5 (`ng update`): **0.5 day** — the schematic does most of the work given zero deprecated-API usage found.
|
||||
- Step 6 (route-param audit): **0.5-1 day** — the one genuinely manual, judgment-requiring step; depends on how many nested-param routes actually exist and how many read parent params today (needs a route-by-route trace, not estimated further without doing that trace).
|
||||
- Step 7 (verification): **0.5-1 day** — matches the RC walkthrough pass's effort, since that's the closest analog in this codebase's own history.
|
||||
|
||||
**Total: ~2-3.5 days** for one engineer, assuming no surprises in the route-param audit (the one genuinely unknown risk). This is a small-to-medium upgrade, not a large one — the app's existing standalone/signals/OnPush posture did the hard work already.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Safe to schedule. Not urgent (still only one major behind), but low-risk and the gap only grows if deferred further. Do the two prerequisite fixes (`barry-cache`, `primeng` removal) regardless of upgrade timing — they're blocking other things too (this dependency chain is also what's stopping the `primeng` bundle-size win noted in `docs/PERFORMANCE_REPORT.md`).
|
||||
@@ -1,73 +0,0 @@
|
||||
# ARCHITECTURE
|
||||
|
||||
## Platform principles
|
||||
|
||||
- One codebase, unlimited tenants. No tenant-specific implementation code in the frontend.
|
||||
- Tenant behavior is controlled entirely by configuration loaded at bootstrap (`GET /bootstrap`, tenant resolved server-side by domain).
|
||||
- Prefer configuration over conditionals, composition over inheritance.
|
||||
- Authentication, payment, and authorization contracts/behavior are frozen and must not be redesigned as part of platform work (`docs/architecture/foundation/adr/ADR-010-backward-compatibility-for-auth-payment-authorization.md`).
|
||||
- No circular dependencies; shared/UI layers are feature-agnostic.
|
||||
|
||||
These rules are enforced, not aspirational — see `docs/architecture/foundation/README.md` and the ADR set below, plus `npm run arch:check` (import-boundary + circular-dependency checks).
|
||||
|
||||
## Architecture Decision Records (source of truth — read directly, do not treat this file as a paraphrase)
|
||||
|
||||
All under `docs/architecture/foundation/adr/`:
|
||||
|
||||
- **ADR-001** — platform model (multi-tenant, config-driven).
|
||||
- **ADR-002** — layered feature architecture.
|
||||
- **ADR-003** — import boundaries and dependency direction.
|
||||
- **ADR-004** — configuration bootstrap and provider abstraction.
|
||||
- **ADR-005** — dynamic page/section/widget rendering.
|
||||
- **ADR-006** — UI component purity and container/facade pattern.
|
||||
- **ADR-007** — state management and facade boundaries.
|
||||
- **ADR-008** — theme engine and design-token runtime.
|
||||
- **ADR-009** — feature flags and capability guards.
|
||||
- **ADR-010** — backward compatibility for auth/payment/authorization.
|
||||
- **ADR-011** — optional Seller Management module (typed foundation only, not built; see `docs/architecture/foundation/Seller-Management-Diagrams.md`).
|
||||
|
||||
Companion standards docs (also `docs/architecture/foundation/`, kept as-is, enforced): `Coding-Standards.md`, `Naming-Conventions.md`, `Dependency-Rules.md`, `Folder-Blueprint.md`, `Import-Boundary-Matrix.md`, `State-Management-Standards.md`, `Configuration-Standards.md`, `Component-Standards.md`, `Service-Standards.md`.
|
||||
|
||||
## Layered architecture
|
||||
|
||||
```
|
||||
Component (container) --> Facade --> Domain Service --> Repository/Provider --> Mock | API
|
||||
```
|
||||
|
||||
- **Container/page components** own routing, orchestration, and DI of a facade. They hold no business logic.
|
||||
- **Presentational components** are `@Input()`/`@Output()`-only: no `HttpClient`, no storage, no environment access, no facade injection (ADR-006). The Project Editor's *sections* (`features/project-editor/sections/*`) are an accepted exception — they are container/section components, not shared presentational UI, so they may inject the facade directly (see `docs/EDITOR.md`).
|
||||
- **Facades** (`facades/**`, or feature-local `facade/`) are the only thing components talk to. They expose signals/observables and imperative methods; they compose one or more domain services (ADR-007).
|
||||
- **Domain services** (`core/<domain>/*.service.ts`) convert backend DTOs into domain models via a **mapper**, and expose domain-shaped methods. DTOs never leak past the mapper boundary.
|
||||
- **Repositories/providers** are swappable via injection tokens (e.g. `PRODUCT_DATA_PROVIDER`, `CATEGORY_REPOSITORY`, `BACKOFFICE_DATA_PROVIDER`, `ADMIN_DASHBOARD_METRICS_GATEWAY`) so mock and real-API implementations can be swapped without touching facades or components — the same pattern used throughout `core/`, `features/admin/*`, and `features/backoffice/*`.
|
||||
|
||||
## Bootstrap / configuration engine
|
||||
|
||||
- `ConfigService` loads `BootstrapConfig` (see `docs/BACKEND.md#1-bootstrap`) once at startup; `PlatformRuntimeService` applies it (theme, branding, runtime state) and can `reloadFromBootstrap()` for in-memory preview without a full page reload.
|
||||
- The bootstrap is the single source of truth for pages, sections, widgets, theme, navigation, footer, static pages, and feature flags (ADR-004).
|
||||
- The Project Editor mutates an in-memory draft of the same `BootstrapConfig` — there is no parallel editor-only model.
|
||||
|
||||
## Dynamic page / section / widget rendering (ADR-005)
|
||||
|
||||
Render pipeline: `page config -> section engine -> section renderer -> widget host -> registered widget component`.
|
||||
|
||||
- **Section Engine** (`dynamic-renderer/section-engine/section-engine.service.ts`) builds an ordered page render model from `PageConfig.sections`, applying `order`, `layout` (`SectionLayoutConfig.strategy`: `stack | grid | hero | carousel | split`), and `visibility` (desktop/tablet/mobile).
|
||||
- **Page Renderer** (`dynamic-renderer/page-renderer/page-renderer.service.ts`) delegates to the Section Engine.
|
||||
- **Widget Host** (`dynamic-renderer/widget-host/widget-host.service.ts`) resolves each widget's component via the **Widget Manifest** (`widgets/registry/widget-manifest.service.ts`, `widgets/contracts/widget-manifest.contract.ts`) and its data via the **Data Source Resolver** (`widgets/resolvers/data-source-resolver.service.ts`), which delegates to `CategoryFacade`/`ProductFacade` — widgets never call APIs directly.
|
||||
- Widgets receive only `{ section config, resolved data }` as inputs; they render presentation only, never fetch or mutate.
|
||||
- Unknown/unregistered widget types render a safe fallback; this is also surfaced in `features/diagnostics` (dev-only, route `/__diagnostics`).
|
||||
- `dynamic-page-layout.component.ts` (`layouts/containers/`) is the top-level container that composes Section Engine output using `PlatformLayoutConfig.type` (`default | sidebar-left | carousel-home | minimal`).
|
||||
|
||||
## Theme engine (ADR-008)
|
||||
|
||||
- `ThemeConfig` (`shared/models/config/theme.model.ts`): `themeId`, `mode` (`light | dark | system`), `palette` (12 semantic colors), `typography`, `spacing`, `borderRadiusScale`, `shadows`, `iconSet`.
|
||||
- Applied as CSS custom properties at runtime; components/widgets consume tokens, never hardcoded brand colors.
|
||||
- Three tenant theme stylesheets live under `src/styles/themes/*.theme.scss` — see `docs/FRONTEND.md` for the CSS custom property convention.
|
||||
|
||||
## Feature flags / capability guards (ADR-009)
|
||||
|
||||
- `bootstrap.featureFlags` (typed) plus the broader `bootstrap.features` (`MarketplaceFeaturesConfig`) surface for UI-facing toggles (wishlist, compare, reviews, recommendations, search history, etc.).
|
||||
- Feature resolution falls back across older config surfaces to preserve behavior as the flag model evolved across sprints — see `docs/BACKEND.md#1-bootstrap` for the full field list.
|
||||
|
||||
## Diagnostics (dev-only)
|
||||
|
||||
`features/diagnostics/` (route `/__diagnostics`, excluded from production) validates bootstrap structure (missing fields, unknown widget types, duplicate ids, unknown layout values, missing translations) and runtime health (widget render failures, missing datasources), scored 0-100. Useful when investigating a bootstrap authored by the Project Editor.
|
||||
5209
docs/BACKEND.md
5209
docs/BACKEND.md
File diff suppressed because it is too large
Load Diff
373
docs/BRAND-BOOTSTRAP.md
Normal file
373
docs/BRAND-BOOTSTRAP.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# Brand bootstrap — full JSON reference
|
||||
|
||||
What one JSON document must contain to turn this codebase into a live, branded marketplace. Frontend is Angular 22, multi-tenant, one bundle for every domain — a brand is 100% config, zero code or rebuild. Source of truth for wire shape: [`bootstrap-config.model.ts`](../src/app/shared/models/config/bootstrap-config.model.ts) and its per-section models in the same folder. Backend contract: [`BACKEND-INTEGRATION.md`](backend/BACKEND-INTEGRATION.md). Deploy/domain/TLS mechanics: [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Request arrives at `https://<any-domain>`.
|
||||
2. nginx forwards the verified `Host` to the API as `X-Storefront-Host`. **Tenant identity comes only from this header — never from a client-supplied field.**
|
||||
3. SPA calls `GET /bootstrap` (also proxied through `api.<base-domain>`).
|
||||
4. Backend resolves tenant from the host, returns this JSON. Frontend renders entirely from it — theme, nav, pages, feature flags, locales.
|
||||
5. One backend, many brands: each `Marketplace` row + its `MarketplaceDomain` rows is a brand. No per-brand deploy.
|
||||
|
||||
Acceptance check used in CI: `curl -fsS https://api.<domain>/bootstrap | jq -e 'type=="object"'`.
|
||||
|
||||
## Minimal path to a new brand
|
||||
|
||||
1. Backend: create a `Marketplace` row (`docs/backend/BACKEND-INTEGRATION.md` §11) and at least one `MarketplaceDomain` (`type: 'production'`).
|
||||
2. Point the domain's DNS A record at the server.
|
||||
3. TLS: either it's a `*.yourapex.com` subdomain (wildcard, zero extra work — [`DEPLOYMENT.md`](DEPLOYMENT.md) §4.1) or a customer's own domain (`add-domain.sh`, §4.4, or the `sync-domains.sh` reconciler, §4.2).
|
||||
4. `configure-api-domain.sh` for the base domain — creates `api.<domain>` (backend proxy, CORS, cert). One API hostname per base domain; subdomains reuse it.
|
||||
5. Backend returns a populated bootstrap JSON for that `Host`. Nothing to redeploy on the frontend side.
|
||||
6. Verify: `curl -I https://<domain>/health` (nginx, expect 200) and `curl -fsS https://api.<domain>/bootstrap | jq .` (backend, expect the object below).
|
||||
|
||||
---
|
||||
|
||||
## Full annotated example
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"generatedAt": "2026-08-22T00:00:00Z",
|
||||
|
||||
"tenant": {
|
||||
"id": "tenant-acme-001",
|
||||
"slug": "acme",
|
||||
"code": "ACME",
|
||||
"host": "shop.acme.com",
|
||||
"name": "Acme Marketplace",
|
||||
"websiteBaseUrl": "https://shop.acme.com",
|
||||
"builderBaseUrl": "https://builder.shop.acme.com",
|
||||
"backofficeBaseUrl": "https://backoffice.shop.acme.com",
|
||||
"defaultLocale": "en",
|
||||
"supportedLocales": ["en", "ru"],
|
||||
"defaultCurrency": "USD",
|
||||
"supportedCurrencies": ["USD", "EUR"],
|
||||
"timezone": "America/New_York",
|
||||
"documentationUrl": "https://docs.shop.acme.com"
|
||||
},
|
||||
|
||||
"branding": {
|
||||
"brandName": "Acme",
|
||||
"legalName": "Acme Commerce LLC",
|
||||
"slogan": "Everything, delivered",
|
||||
"logoUrl": "https://cdn.acme.com/logo.svg",
|
||||
"logoCompactUrl": "https://cdn.acme.com/logo-compact.svg",
|
||||
"faviconUrl": "https://cdn.acme.com/favicon.ico",
|
||||
"appIconUrl": "https://cdn.acme.com/icon-192.png",
|
||||
"supportEmail": "support@acme.com",
|
||||
"supportPhone": "+1-555-000-0000"
|
||||
},
|
||||
|
||||
"theme": {
|
||||
"themeId": "acme-light",
|
||||
"mode": "light",
|
||||
"palette": {
|
||||
"primary": "#1a56db",
|
||||
"secondary": "#7e8a97",
|
||||
"accent": "#60a5fa",
|
||||
"success": "#10b981",
|
||||
"warning": "#f59e0b",
|
||||
"danger": "#ef4444",
|
||||
"info": "#3b82f6",
|
||||
"textPrimary": "#111827",
|
||||
"textSecondary": "#6b7280",
|
||||
"backgroundPrimary": "#ffffff",
|
||||
"backgroundSecondary": "#f9fafb",
|
||||
"border": "#e5e7eb"
|
||||
},
|
||||
"typography": {
|
||||
"primaryFontFamily": "Inter, sans-serif",
|
||||
"headingFontFamily": "Inter, sans-serif",
|
||||
"baseFontSize": 16
|
||||
},
|
||||
"spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] },
|
||||
"borderRadiusScale": { "sm": "6px", "md": "10px", "lg": "14px", "xl": "20px" },
|
||||
"shadows": {
|
||||
"sm": "0 2px 8px rgba(0,0,0,0.1)",
|
||||
"md": "0 4px 12px rgba(0,0,0,0.15)",
|
||||
"lg": "0 12px 32px rgba(26,86,219,0.2)"
|
||||
},
|
||||
"iconSet": "default"
|
||||
},
|
||||
|
||||
"company": {
|
||||
"companyName": "Acme Commerce LLC",
|
||||
"registrationNumber": "0000000000",
|
||||
"taxId": "00-0000000",
|
||||
"address": {
|
||||
"country": "USA",
|
||||
"region": "NY",
|
||||
"city": "New York",
|
||||
"street": "5th Ave 1",
|
||||
"postalCode": "10001"
|
||||
},
|
||||
"contacts": {
|
||||
"email": "support@acme.com",
|
||||
"phone": "+1-555-000-0000",
|
||||
"telegram": "@acme_support",
|
||||
"website": "https://acme.com"
|
||||
}
|
||||
},
|
||||
|
||||
"featureFlags": {
|
||||
"wishlist": true, "compare": true, "reviews": true, "blog": false,
|
||||
"chat": false, "analytics": true, "notifications": true,
|
||||
"coupons": true, "loyalty": false, "giftCards": false, "invoices": true
|
||||
},
|
||||
|
||||
"apiEndpoints": {
|
||||
"bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 },
|
||||
"website": {}, "builder": {}, "backoffice": {}
|
||||
},
|
||||
|
||||
"localization": {
|
||||
"defaultLocale": "en",
|
||||
"supportedLocales": ["en", "ru"],
|
||||
"currencyByLocale": { "en": "USD", "ru": "RUB" },
|
||||
"dictionaries": [
|
||||
{ "locale": "en", "dictionaryUrl": "/assets/i18n/en.json", "version": "1.0.0" },
|
||||
{ "locale": "ru", "dictionaryUrl": "/assets/i18n/ru.json", "version": "1.0.0" }
|
||||
]
|
||||
},
|
||||
|
||||
"seo": {
|
||||
"default": { "title": "Acme", "description": "Everything, delivered", "robots": "index,follow" },
|
||||
"byPageKey": {
|
||||
"home": { "title": "Acme - Home", "description": "Everything, delivered", "canonicalUrl": "https://shop.acme.com/", "robots": "index,follow" }
|
||||
}
|
||||
},
|
||||
|
||||
"permissions": {
|
||||
"definitions": [
|
||||
{ "key": "builder.pages.edit", "description": "Edit pages in builder" },
|
||||
{ "key": "backoffice.products.read", "description": "Read products in backoffice" }
|
||||
],
|
||||
"roles": [
|
||||
{ "role": "builder_admin", "permissions": ["builder.pages.edit"] },
|
||||
{ "role": "backoffice_manager", "permissions": ["backoffice.products.read"] }
|
||||
]
|
||||
},
|
||||
|
||||
"header": { "showLogo": true, "showSearch": true, "showCategories": true, "showCart": true, "sticky": true, "layout": "default" },
|
||||
"layout": { "type": "default" },
|
||||
|
||||
"navigation": {
|
||||
"header": [
|
||||
{ "id": "nav-home", "labelKey": "nav.home", "route": "/", "icon": "home", "order": 1 },
|
||||
{ "id": "nav-search", "labelKey": "nav.search", "route": "/search", "icon": "search", "order": 2 },
|
||||
{ "id": "nav-cart", "labelKey": "nav.cart", "route": "/cart", "icon": "cart", "order": 3 }
|
||||
],
|
||||
"footer": [
|
||||
{ "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 },
|
||||
{ "id": "footer-privacy", "labelKey": "nav.privacy", "route": "/privacy-policy", "order": 2 }
|
||||
]
|
||||
},
|
||||
|
||||
"footer": {
|
||||
"paymentIcons": [{ "src": "/assets/images/visa-logo.svg", "alt": "Visa", "width": 40, "height": 28 }],
|
||||
"copyrightText": { "en": "© 2026 Acme. All rights reserved.", "ru": "© 2026 Acme. Все права защищены." },
|
||||
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
|
||||
},
|
||||
|
||||
"catalog": {
|
||||
"layout": "grid",
|
||||
"navigationMode": "default",
|
||||
"defaultSort": "relevance",
|
||||
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
|
||||
"enabledFilters": ["price", "availability", "rating", "brand", "category"],
|
||||
"showBreadcrumbs": true, "showCategoryBanner": true, "showRatings": true,
|
||||
"showDiscounts": true, "showAvailability": true, "suggestionsEnabled": true, "searchHistoryEnabled": true
|
||||
},
|
||||
|
||||
"productPage": {
|
||||
"rating": { "enabled": true },
|
||||
"reviews": { "enabled": true, "pageSize": 5, "showSummary": true },
|
||||
"questions": { "enabled": true, "pageSize": 5 },
|
||||
"tabs": { "enabled": true, "items": ["description", "specifications", "reviews", "questions", "delivery", "warranty"] },
|
||||
"relatedProducts": { "enabled": true }
|
||||
},
|
||||
|
||||
"userExperience": {
|
||||
"wishlist": { "enabled": true, "headerBadgeEnabled": true },
|
||||
"compare": { "enabled": true, "maxItems": 4, "hideIdenticalDefault": false, "highlightDifferencesDefault": true },
|
||||
"recentlyViewed": { "enabled": true, "maxItems": 12, "widgetEnabled": true },
|
||||
"share": { "enabled": true },
|
||||
"continueBrowsing": { "enabled": true },
|
||||
"savedSearches": { "enabled": true, "maxItems": 10 }
|
||||
},
|
||||
|
||||
"features": {
|
||||
"wishlist": true, "compare": true, "reviews": true, "comments": true,
|
||||
"questions": true, "recommendations": true, "recentlyViewed": true,
|
||||
"searchHistory": true, "recentlySearched": true, "ratings": true,
|
||||
"share": true, "brands": true, "manufacturers": true,
|
||||
"availability": true, "discounts": true, "badges": true
|
||||
},
|
||||
|
||||
"widgetRegistry": { "manifestUrl": "https://api.acme.com/widget-manifest.json" },
|
||||
|
||||
"staticPages": {
|
||||
"about-us": {
|
||||
"route": "/about-us",
|
||||
"title": { "en": "About Us", "ru": "О компании" },
|
||||
"html": { "en": "<h2>About Us</h2><p>...</p>", "ru": "<h2>О компании</h2><p>...</p>" }
|
||||
}
|
||||
},
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page-home", "key": "home", "title": "Home",
|
||||
"route": { "path": "/", "exact": true },
|
||||
"layout": { "type": "default" },
|
||||
"seoKey": "home", "visible": true,
|
||||
"sections": [
|
||||
{
|
||||
"id": "section-hero", "type": "hero", "order": 1,
|
||||
"layout": { "strategy": "hero", "columns": 1, "gap": "1.5rem", "align": "stretch" },
|
||||
"visibility": { "desktop": true, "tablet": true, "mobile": true },
|
||||
"visible": true,
|
||||
"widgets": [
|
||||
{
|
||||
"id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1,
|
||||
"padding": "0.5rem 0",
|
||||
"visibility": { "desktop": true, "tablet": true, "mobile": true },
|
||||
"visible": true,
|
||||
"props": {
|
||||
"title": { "en": "Welcome to Acme", "ru": "Добро пожаловать в Acme" },
|
||||
"subtitle": { "en": "Everything, delivered", "ru": "Всё, с доставкой" },
|
||||
"ctaLabel": { "en": "Start Shopping", "ru": "Начать покупки" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"modules": { "sellerManagement": { "enabled": false } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field reference
|
||||
|
||||
### `tenant` (required) — [`tenant.model.ts`](../src/app/shared/models/config/tenant.model.ts)
|
||||
|
||||
Identity and locale/currency defaults. `host` must exactly match the domain nginx forwards — mismatches are how tenant leakage bugs happen. `websiteBaseUrl` / `builderBaseUrl` / `backofficeBaseUrl` are the three surfaces this same brand can present (storefront, page builder, admin backoffice) — each gets its own subdomain or host.
|
||||
|
||||
### `branding` (required) — [`branding.model.ts`](../src/app/shared/models/config/branding.model.ts)
|
||||
|
||||
Everything a human sees as "this is the brand": name, logo variants, favicon, support contact. `logoCompactUrl` is used where header space is tight (mobile, collapsed nav).
|
||||
|
||||
### `theme` (required) — [`theme.model.ts`](../src/app/shared/models/config/theme.model.ts)
|
||||
|
||||
Full design-token set: color palette, typography, spacing scale, border radii, shadows. Consumed by [`theme-css-vars.mapper.ts`](../src/app/theme/mappers/theme-css-vars.mapper.ts) → CSS custom properties at runtime. `mode` is `light` or `dark`; ship a matching palette for whichever `themeId` you pick.
|
||||
|
||||
### `company` (required) — [`company.model.ts`](../src/app/shared/models/config/company.model.ts)
|
||||
|
||||
Legal/registration data for invoices, footer legal text, compliance pages. Not user-facing branding — this is the registered entity behind the brand.
|
||||
|
||||
### `featureFlags` (required) — [`feature-flags.model.ts`](../src/app/shared/models/config/feature-flags.model.ts)
|
||||
|
||||
Coarse on/off switches for major product areas (wishlist, blog, chat, loyalty, gift cards, invoices...). Distinct from `features` below — this set gates bigger surfaces.
|
||||
|
||||
### `features` (optional) — [`features-config.model.ts`](../src/app/shared/models/config/features-config.model.ts)
|
||||
|
||||
Finer-grained per-marketplace toggles (comments, recommendations, badges, etc). Omit any key to fall back to `DEFAULT_MARKETPLACE_FEATURES_CONFIG` (all `true`).
|
||||
|
||||
### `apiEndpoints` (required) — [`api-endpoints.model.ts`](../src/app/shared/models/config/api-endpoints.model.ts)
|
||||
|
||||
Per-surface endpoint overrides. `bootstrap` itself is always required; `website`/`builder`/`backoffice` may stay empty objects to use defaults.
|
||||
|
||||
### `localization` (required) — [`localization.model.ts`](../src/app/shared/models/config/localization.model.ts)
|
||||
|
||||
Locale list, default, per-locale currency, and dictionary URLs (`/assets/i18n/<locale>.json` or a CDN URL). Every locale in `tenant.supportedLocales` needs an entry here.
|
||||
|
||||
### `seo` (required) — [`seo.model.ts`](../src/app/shared/models/config/seo.model.ts)
|
||||
|
||||
Default meta tags plus per-`pageKey` overrides, consumed by [`seo.service.ts`](../src/app/services/seo.service.ts).
|
||||
|
||||
### `permissions` (required) — [`permissions.model.ts`](../src/app/shared/models/config/permissions.model.ts)
|
||||
|
||||
Role → permission-key map used by frontend guards. The frontend never hardcodes role logic beyond hiding affordances — see `BACKEND-INTEGRATION.md` §4.6; the authoritative check still happens server-side per request.
|
||||
|
||||
### `header` (optional) — [`header-config.model.ts`](../src/app/shared/models/config/header-config.model.ts)
|
||||
|
||||
Which header elements show (`showSearch`, `showCart`, `showRegion`, ...) and `layout` (`default` | `centered`). Omit to use `DEFAULT_HEADER_CONFIG`.
|
||||
|
||||
### `catalog` (optional) — [`catalog-config.model.ts`](../src/app/shared/models/config/catalog-config.model.ts)
|
||||
|
||||
Product-listing behavior: layout, sort options, enabled filters, which badges/breadcrumbs show.
|
||||
|
||||
### `layout` (optional) — [`layout.model.ts`](../src/app/shared/models/config/layout.model.ts)
|
||||
|
||||
Top-level page shell type.
|
||||
|
||||
### `navigation` (required) — [`navigation.model.ts`](../src/app/shared/models/config/navigation.model.ts)
|
||||
|
||||
Header and footer link lists, each entry `{ id, labelKey, route, icon?, order }`. `labelKey` resolves against the locale dictionaries in `localization`.
|
||||
|
||||
### `footer` (optional) — [`footer-config.model.ts`](../src/app/shared/models/config/footer-config.model.ts)
|
||||
|
||||
Payment-method icons, per-locale copyright text, legal page keys to link.
|
||||
|
||||
### `productPage` (optional) — [`product-page-config.model.ts`](../src/app/shared/models/config/product-page-config.model.ts)
|
||||
|
||||
Reviews, questions, tabs, related-products behavior on the PDP.
|
||||
|
||||
### `userExperience` (optional) — [`user-experience-config.model.ts`](../src/app/shared/models/config/user-experience-config.model.ts)
|
||||
|
||||
Wishlist, compare, recently-viewed, share, saved-searches — limits and toggles.
|
||||
|
||||
### `pages` (required) — [`page.model.ts`](../src/app/shared/models/config/page.model.ts)
|
||||
|
||||
The actual page tree. Each page has a route, layout, and a `sections[]` list; each section has `layout` (`hero` | `grid` | `carousel` | ...), responsive `visibility`, and `widgets[]`. Each widget references a `type` + `version` resolved against the widget manifest (see `widgetRegistry`) and carries its own `props` (usually per-locale strings). This is what the page builder edits and what [`section-engine.service.ts`](../src/app/dynamic-renderer/section-engine/section-engine.service.ts) renders.
|
||||
|
||||
### `staticPages` (optional) — [`static-page.model.ts`](../src/app/shared/models/config/static-page.model.ts)
|
||||
|
||||
Simple route → per-locale `{ title, html }` pages (about, privacy, terms, contacts) that don't need the full section/widget builder.
|
||||
|
||||
### `widgetRegistry` (optional) — [`widget-registry.model.ts`](../src/app/shared/models/config/widget-registry.model.ts)
|
||||
|
||||
URL to the widget manifest — the catalog of widget types/versions this brand's `pages[].sections[].widgets[]` are allowed to reference. See [`widget-manifest.service.ts`](../src/app/widgets/registry/widget-manifest.service.ts).
|
||||
|
||||
### `modules` (optional) — [`platform-modules.model.ts`](../src/app/shared/models/config/platform-modules.model.ts)
|
||||
|
||||
Platform-level capability gates that introduce a whole new scope (currently just `sellerManagement`), not a simple toggle. Absent or `undefined` = every module disabled, and existing marketplaces that never send this field behave exactly as before (ADR-011). A disabled module must add zero new routes/menus/API calls.
|
||||
|
||||
### `seller` (optional, backend-resolved only) — [`seller.model.ts`](../src/app/shared/models/config/seller.model.ts)
|
||||
|
||||
Present only when `modules.sellerManagement.enabled` is `true` **and** the request resolves beneath a specific seller. The frontend never decides this itself — same rule as tenant resolution (ADR-001): the backend resolves scope from the verified host/session, never from a client-supplied field.
|
||||
|
||||
---
|
||||
|
||||
## Required vs optional at a glance
|
||||
|
||||
| Required | Optional (sensible defaults exist) |
|
||||
|---|---|
|
||||
| `schemaVersion`, `generatedAt` | `features` |
|
||||
| `tenant` | `header` |
|
||||
| `branding` | `catalog` |
|
||||
| `theme` | `layout` |
|
||||
| `company` | `footer` |
|
||||
| `featureFlags` | `productPage` |
|
||||
| `apiEndpoints` | `userExperience` |
|
||||
| `localization` | `staticPages` |
|
||||
| `seo` | `widgetRegistry` |
|
||||
| `permissions` | `modules` |
|
||||
| `navigation` | `seller` (backend-resolved, never client-set) |
|
||||
| `pages` | |
|
||||
|
||||
## Going live — checklist
|
||||
|
||||
- [ ] `Marketplace` row created (§11 of `BACKEND-INTEGRATION.md`), `lifecycleState` progressed to `production_ready`
|
||||
- [ ] `MarketplaceDomain` row(s) added, `type: 'production'`
|
||||
- [ ] DNS A record → server IP
|
||||
- [ ] TLS: wildcard subdomain (no action) or `add-domain.sh` / reconciler for a custom domain
|
||||
- [ ] `api.<base-domain>` configured (`configure-api-domain.sh`) — CORS echoes the exact storefront origin, never `*` with credentials
|
||||
- [ ] Backend returns full bootstrap JSON for that `Host` — validate with `curl -fsS https://api.<domain>/bootstrap | jq .`
|
||||
- [ ] `curl -I https://<domain>/health` → `200`
|
||||
- [ ] Every locale in `tenant.supportedLocales` has a `localization.dictionaries[]` entry and a `localization.currencyByLocale` entry
|
||||
- [ ] `navigation.header`/`footer` routes match real routes; `staticPages`/`pages[].route` keys line up with `legalPageKeys`
|
||||
286
docs/DEPLOYMENT.md
Normal file
286
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Deployment — server provisioning, CD, TLS
|
||||
|
||||
Frontend deployment plus API-domain edge configuration. The backend service is a
|
||||
separate developer's responsibility. API hostnames are separate reverse proxies
|
||||
and return `502` until their configured upstream exists (production currently
|
||||
defaults to `https://127.0.0.1:445`).
|
||||
|
||||
**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-INTEGRATION.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
|
||||
|
||||
The SPA derives one API origin from the storefront's base domain:
|
||||
`example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
`api.example.com`. Tenant identity still comes from the complete storefront
|
||||
host; tenant subdomains do not create additional API DNS names.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. |
|
||||
| `scripts/deploy/add-domain.sh` | Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. |
|
||||
| `scripts/deploy/configure-api-domain.sh` | Configure shared `api.<base domain>` TLS, storefront-origin CORS, backend proxy, and JSON bootstrap verification. |
|
||||
| `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Layout on the server
|
||||
|
||||
```
|
||||
/srv/marketplaces/
|
||||
├── releases/
|
||||
│ ├── a1b2c3d4e5f6/frontend/ <- one directory per deployed commit
|
||||
│ └── ... (last 5 kept)
|
||||
└── current -> releases/a1b2c3d4e5f6
|
||||
```
|
||||
|
||||
nginx root is `/srv/marketplaces/current/frontend`. Activation is a symlink swap, so no request is ever served from a half-written directory, and a rollback is a symlink change rather than a rebuild.
|
||||
|
||||
**On the current production host there is one extra hop.** That server predates
|
||||
`server-setup.sh` and was provisioned by hand, so instead of the catch-all vhost
|
||||
it has per-domain configs (`gorbushka.conf`, `dexarmarket.conf`,
|
||||
`gorbushka-admin.conf`, `gorbushka-landing.conf`) whose `root` is
|
||||
`/var/www/dexarmarket/browser`. That path is itself a symlink:
|
||||
|
||||
```
|
||||
/var/www/dexarmarket/browser -> /srv/marketplaces/current/frontend
|
||||
```
|
||||
|
||||
so the release/`current` model above still holds and the workflow needs no
|
||||
per-host special-casing. Until 2026-08-22 `browser` pointed straight at one
|
||||
pinned release directory with no `current` in between, which is why two
|
||||
successfully-uploaded releases sat unserved.
|
||||
|
||||
---
|
||||
|
||||
## 3. First-time setup
|
||||
|
||||
### 3.1 Generate a CI deploy key
|
||||
|
||||
On your machine, **not** on the server:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "ci@marketplaces" -f ./marketplaces_deploy -N ""
|
||||
```
|
||||
|
||||
Two files result. `marketplaces_deploy.pub` goes to the server; `marketplaces_deploy` (private) goes into CI secrets and nowhere else.
|
||||
|
||||
### 3.2 Provision the server
|
||||
|
||||
Copy `scripts/deploy/` to the server and run:
|
||||
|
||||
```bash
|
||||
sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)"
|
||||
```
|
||||
|
||||
This installs nginx + certbot, creates a **key-only** `deploy` user with no
|
||||
password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and
|
||||
installs a root-owned, argument-validating API-domain helper. The deploy user may
|
||||
run that helper and reload nginx, but cannot replace the helper.
|
||||
|
||||
It also applies host hardening (added 2026-08-21, FH-D.3) — three drop-in files,
|
||||
so a re-run replaces its own config and never edits a distro file in place:
|
||||
|
||||
| File | Effect |
|
||||
|---|---|
|
||||
| `/etc/ssh/sshd_config.d/10-marketplaces-hardening.conf` | Password and keyboard-interactive auth off, root key-only, no agent/X11 forwarding, `MaxAuthTries 3`, 30 s login grace |
|
||||
| `/etc/fail2ban/jail.d/marketplaces.local` | `sshd`, `nginx-http-auth`, `nginx-bad-request` jails — 5 failures in 10 min, 1 h ban |
|
||||
| `/etc/sysctl.d/99-marketplaces-hardening.conf` | No redirects or source routing, reverse-path filtering, SYN cookies, forwarding off, restricted kernel pointers and dmesg |
|
||||
|
||||
Both accounts on this host are key-only by construction, so disabling password
|
||||
auth cannot lock anyone out — it only closes unlimited guessing against a
|
||||
credential nobody intended to exist. The script runs `sshd -t` before reloading
|
||||
and removes its own drop-in if the test fails, because a bad sshd config taking
|
||||
effect on a remote box is how people lock themselves out permanently.
|
||||
|
||||
Confirm after provisioning:
|
||||
|
||||
```bash
|
||||
sudo fail2ban-client status sshd
|
||||
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin|maxauthtries'
|
||||
```
|
||||
|
||||
Verify before continuing:
|
||||
|
||||
```bash
|
||||
curl -I http://<server-ip>/health
|
||||
```
|
||||
|
||||
Expect `200`. A placeholder page is served until the first real deploy.
|
||||
|
||||
### 3.3 Capture the host key
|
||||
|
||||
```bash
|
||||
ssh-keyscan -H <server-ip>
|
||||
```
|
||||
|
||||
The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or impersonated server fails the deploy instead of being trusted silently.
|
||||
|
||||
### 3.4 Add CI secrets
|
||||
|
||||
Required for every deploy:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `DEPLOY_HOST` | server IP or hostname |
|
||||
| `DEPLOY_USER` | `deploy` |
|
||||
| `DEPLOY_SSH_KEY` | contents of the **private** key file |
|
||||
| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H <server-ip>` |
|
||||
|
||||
Required **only** when running the workflow with `reconcile_api_domains` on
|
||||
(§4.6) — a normal release deploy never reads these:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `STOREFRONT_DOMAINS` | space-separated full hosts, e.g. `gorbushka.market store1.example.com` |
|
||||
| `CERTBOT_EMAIL` | operations email used for Let's Encrypt |
|
||||
| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` |
|
||||
|
||||
When that step does run, point each base domain's shared API hostname at the
|
||||
server first. For `gorbushka.market` and `store1.gorbushka.market`, only
|
||||
`api.gorbushka.market` is required. The workflow deduplicates
|
||||
`STOREFRONT_DOMAINS` by base domain and deliberately stops before release
|
||||
activation if DNS, certificate issuance, nginx validation, or the JSON
|
||||
`/bootstrap` check fails.
|
||||
|
||||
### 3.5 Deploy
|
||||
|
||||
Push to `main`, or run the workflow manually with a ref. The workflow refuses to swap the symlink unless the uploaded release contains an `index.html`, so a failed upload leaves the previous release serving.
|
||||
|
||||
---
|
||||
|
||||
## 4. Domains and TLS — dynamic by default
|
||||
|
||||
Domains arrive continuously: one today, five tomorrow. Nothing here requires a person per domain.
|
||||
|
||||
**HTTP already needs zero configuration.** The nginx catch-all serves *any* `Host`, and the SPA resolves its tenant from that header. Point a domain's A record at the server and it works over port 80 immediately. Only TLS needs a certificate per name — that is the whole problem this section solves.
|
||||
|
||||
Two mechanisms, used together:
|
||||
|
||||
### 4.1 Wildcard — tenants on our own apex
|
||||
|
||||
One certificate covers every `<slug>.<apex>`. A new tenant subdomain is then live over HTTPS the moment DNS resolves, with **no certificate work at all**.
|
||||
|
||||
```bash
|
||||
sudo bash setup-wildcard-tls.sh \
|
||||
--apex marketplaces.example.com \
|
||||
--email ops@example.com \
|
||||
--dns cloudflare --creds /root/cloudflare.ini
|
||||
```
|
||||
|
||||
Wildcards require DNS-01 validation, so certbot must write a `_acme-challenge` TXT record. With a provider plugin (`cloudflare`, `route53`) renewal is unattended. `--dns manual` works but prompts for a TXT record at **every** renewal — fine to prove the setup out, not acceptable as a steady state.
|
||||
|
||||
**Hostinger has no certbot plugin.** If DNS lives there: either move DNS to a provider that has one (Cloudflare is free, minutes of work), or drive issuance from the [Phase 9](backend/BACKEND-INTEGRATION.md) domain-automation API once it exists.
|
||||
|
||||
### 4.2 Reconciler — tenants on their own domains
|
||||
|
||||
A wildcard cannot cover a customer's own domain. `sync-domains.sh` runs on a 10-minute timer and reconciles the live set against a desired list:
|
||||
|
||||
- issues certificates for domains that lack one
|
||||
- skips domains whose certificate has more than 30 days left
|
||||
- skips subdomains already covered by `WILDCARD_APEX`
|
||||
- leaves domains alone while their DNS has not propagated yet, and retries next tick
|
||||
- disables server blocks for domains removed from the source — **without deleting the certificate**, so re-adding one later is instant
|
||||
- caps issuance per run, so a misconfigured source cannot burn the weekly ACME budget in a single pass
|
||||
|
||||
Configure `/etc/marketplaces/domains.env`:
|
||||
|
||||
```bash
|
||||
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
|
||||
CERTBOT_EMAIL=ops@example.com
|
||||
MAX_ISSUE_PER_RUN=10
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now marketplaces-domains.timer
|
||||
sudo /srv/marketplaces/bin/sync-domains.sh --dry-run # see the plan, change nothing
|
||||
```
|
||||
|
||||
Adding a domain becomes: append a line to `/etc/marketplaces/domains.txt` (or add the row in the backend registry), point DNS, wait one tick.
|
||||
|
||||
### 4.3 Backend-driven, once Phase 9 ships
|
||||
|
||||
Point the reconciler at the registry instead of a file and the loop closes — `MarketplaceDomain` already carries exactly the statuses this needs (`planned → dns_pending → ssl_pending → active → failed`):
|
||||
|
||||
```bash
|
||||
DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains
|
||||
DOMAINS_API_TOKEN=...
|
||||
```
|
||||
|
||||
The script accepts a bare JSON array of hostnames, or objects with `domain` + `status`, in which case it acts only on `active` rows. **A fetch failure aborts the run rather than reading as "remove every domain."**
|
||||
|
||||
### 4.4 One-off
|
||||
|
||||
For a single domain, outside the reconciler:
|
||||
|
||||
```bash
|
||||
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
||||
```
|
||||
|
||||
### 4.5 API domains in CD are opt-in
|
||||
|
||||
The deploy workflow's **Reconcile tenant API domains** step is gated behind the
|
||||
`reconcile_api_domains` input and is **off for push-triggered deploys**.
|
||||
|
||||
`configure-api-domain.sh` writes `/etc/nginx/sites-available/api.<domain>` and
|
||||
enables it. The API vhosts on the current production host were created by hand
|
||||
under different filenames (`gorbushka-api.conf`), so running the helper there
|
||||
produces a *second* server block for a `server_name` that already has one, and
|
||||
re-runs certbot against a live API — on every deploy. Shipping frontend files
|
||||
needs none of that.
|
||||
|
||||
Turn it on from the workflow-dispatch form only when standing up a **new** base
|
||||
domain. Before the first such run, reconcile the naming: either delete the
|
||||
hand-made vhost and let the helper own the name, or leave the step off and keep
|
||||
managing API domains manually.
|
||||
|
||||
### 4.6 Verify
|
||||
|
||||
```bash
|
||||
curl -I https://shop.example.com/health
|
||||
sudo certbot certificates
|
||||
journalctl -u marketplaces-domains.service --since "1 hour ago"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
```bash
|
||||
ssh deploy@<server-ip>
|
||||
ls -1dt /srv/marketplaces/releases/*/ # newest first
|
||||
ln -sfnT /srv/marketplaces/releases/<sha> /srv/marketplaces/current.new
|
||||
mv -Tf /srv/marketplaces/current.new /srv/marketplaces/current
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Only the last 5 releases are retained. Older ones need a rebuild from the tag.
|
||||
|
||||
The production host reaches releases through `/var/www/dexarmarket/browser ->
|
||||
/srv/marketplaces/current/frontend` (§2), so moving `current` is all a rollback
|
||||
needs there too — do not repoint `browser` at a release directly, or the next
|
||||
deploy's swap will silently stop taking effect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Operational checks
|
||||
|
||||
```bash
|
||||
curl -I http://<host>/health # 200 from nginx
|
||||
readlink -f /srv/marketplaces/current # which commit is live
|
||||
sudo nginx -t # config valid
|
||||
systemctl status nginx certbot.timer # both active
|
||||
sudo tail -f /var/log/nginx/marketplaces.error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Known limits
|
||||
|
||||
- **`/api/` 502s until the backend runs.** Expected. nginx proxies to `127.0.0.1:8080`; nothing listens there yet.
|
||||
- **No staging environment.** `main` goes straight to production. Adding one means a second server plus a `staging` branch trigger.
|
||||
- **No smoke test beyond HTTP 200.** The verify step confirms nginx serves the shell, not that the app boots. A real check needs the E2E harness from Track Q.
|
||||
- **Caching.** `index.html` is `no-store`; hashed assets are `immutable` for a year. A deploy therefore takes effect on the next page load, with no cache purge.
|
||||
163
docs/EDITOR.md
163
docs/EDITOR.md
@@ -1,163 +0,0 @@
|
||||
# EDITOR (Project Editor)
|
||||
|
||||
Replaces the old `docs/Project-Editor.md` (content merged in below and extended with the Sprint 19 field-description/dropdown work).
|
||||
|
||||
The Project Editor (`src/app/features/project-editor/`) edits the tenant's `BootstrapConfig` (`docs/BACKEND.md#1-bootstrap`) directly — no parallel model. It is out of scope for products, categories, orders, or analytics management (those live under `features/admin/*`/`features/backoffice/*`, see `docs/BACKEND.md` §3 CRUD Contracts).
|
||||
|
||||
```
|
||||
src/app/features/project-editor/
|
||||
pages/ route container
|
||||
sections/ one component per editor tab (see below)
|
||||
components/ shared editor UI (save bar, HTML editor)
|
||||
models/ ProjectEditorState, EDITOR_SECTION_BOOTSTRAP_KEYS
|
||||
schema/ field-schema registry, validators/, history.util (Sprint X+1, see below)
|
||||
services/ ProjectValidator, ProjectEditorDraftStorageService, LocaleSyncService
|
||||
facade/ ProjectEditorFacade
|
||||
```
|
||||
|
||||
Route: `/edit/:section` or `/{lang}/edit/:section`. `/backoffice/static-pages` (the Admin dashboard) redirects here (`/edit/static-pages`) rather than hosting a second CRUD surface over the same `bootstrap.staticPages` data (Sprint X+2 — see `docs/StaticPages.md`).
|
||||
|
||||
## Facade
|
||||
|
||||
`ProjectEditorFacade` exposes: `loadBootstrap()`, `updateBootstrap(updater)`, `exportBootstrap()`, `importBootstrap()`, `preview()`, `save()`, `publish()`, `undo()`, `redo()`, plus signals `bootstrap`, `status` (`draft|published`), `dirty`, `canUndo`, `canRedo`, `lastSavedAt`, `lastPublishedAt`, `validationIssues`, `blockingIssues`, `hasBlockingIssues`, `issuesByField`, `issuesBySection`, `modifiedFields`, `modifiedSections`, `changeSummary`, `homepageWidgets`, `homepagePage`, plus the `fieldError(key)` method. Components in `sections/*` inject this facade directly (an accepted exception to the presentational-component rule, per ADR-006 — these are container/section components, not shared UI). See "Configuration schema, form engine, and validation architecture" below for the schema/validator/undo internals.
|
||||
|
||||
## Sections
|
||||
|
||||
| Section | Component | Covers |
|
||||
|---|---|---|
|
||||
| General | `general-section` | marketplace name, domain, description, default/supported languages |
|
||||
| Branding | `branding-section` | logo, small logo, favicon, social share (OG) image, gallery, marketplace title — all image fields use `app-image-field` (thumbnail preview + replace/remove) |
|
||||
| Theme | `theme-section` | palette colors (live, applied as CSS custom properties), theme mode (**not applied at runtime, see Known gaps**), site layout mode |
|
||||
| Header | `header-section` | logo/search/categories/languages/cart/profile/wishlist/compare/region toggles, layout (default/centered), sticky |
|
||||
| Footer | `footer-section` | company info, address, phone, email, copyright, payment icons, social links, static pages list |
|
||||
| Homepage | `homepage-section` | homepage section list: visibility, order (drag-and-drop), layout strategy, columns |
|
||||
| Widgets | `widgets-section` | homepage widget configuration — typed editors for hero/categories/product-collection, JSON fallback (with draft-preserving inline error, not silent-discard) for everything else |
|
||||
| Static Pages | `static-pages-editor` (`features/content-management/`) | Full CRUD, media, SEO, per-page draft/publish, device preview, nav integration — see `docs/StaticPages.md` (Sprint X+2) |
|
||||
| Marketplace Features | `features-section` | feature flags, catalog navigation mode, search suggestions/history, recently viewed, reviews/questions/recommendations |
|
||||
| Languages | `languages-section` | add/remove supported locale, set default locale; syncs translation keys across static pages and nav labels via `LocaleSyncService` |
|
||||
| Navigation | `navigation-section` | header nav: add/remove/reorder/edit label/URL/visibility, per-locale via `app-locale-tabs`. Flat footer nav: same. Grouped (column-based) footer nav is read-only here — edit via Footer tab. |
|
||||
| Preview | `preview-section` | export/import JSON, in-memory runtime preview without full reload |
|
||||
|
||||
## Save / publish / draft / reset model
|
||||
|
||||
- **Save**: `save()` snapshots the current in-memory bootstrap as "last saved" (`lastSavedAt`). `ProjectEditorDraftStorageService` persists the full draft to `localStorage` (`projectEditor.draftBootstrap.v1`, scoped by `tenant.id`) on every `updateBootstrap()`, `save()`, and `publish()` call.
|
||||
- **Publish**: runs `ProjectValidator`; if clean, calls `PlatformRuntimeService.reloadFromBootstrap()`, sets `status = 'published'`, sets `lastPublishedAt`, and becomes the new `originalBootstrap` baseline used by reset.
|
||||
- **Draft restore**: on `loadBootstrap()`, if a stored draft exists for the same tenant it loads instead of the fresh fetch, and `draftRestored` is set (shown as a dismissible banner in the save bar).
|
||||
- **Reset section**: reverts one section's bootstrap keys (per `EDITOR_SECTION_BOOTSTRAP_KEYS` in `models/project-editor.model.ts`) to `originalBootstrap`. Confirmation required.
|
||||
- **Reset draft**: reverts the entire bootstrap to `originalBootstrap` and clears the persisted local draft. Confirmation required.
|
||||
- **Per-field reset is not implemented** — no per-field default registry exists; only section- and project-level reset.
|
||||
- **No backend persistence exists for any of this today** — see `docs/BACKEND.md` §1 (Bootstrap: Draft vs Published) and §8 (Real Backend Implementation Guide) for the endpoints needed.
|
||||
|
||||
## Configuration schema, form engine, and validation architecture (Sprint X+1)
|
||||
|
||||
**Approach: metadata-augmented, not fully schema-driven.** Section templates stay hand-authored (`sections/*.component.html`); a field-schema registry sits alongside them as the single source of truth for field identity, labels, and validator wiring. This was chosen over a schema-driven renderer to preserve every existing template/UX pixel-for-pixel while still centralizing metadata and validation — the highest-value, lowest-regression-risk option given 11 mature section templates already built on the `shared/ui` primitives (see the primitives table above).
|
||||
|
||||
### Field-schema registry (`schema/`)
|
||||
|
||||
- `field-schema.model.ts` — `FieldSchema`: `{ key, section, type, labelKey, hintKey?, default?, required?, validators? }`. `key` is a dot path into `BootstrapConfig` (e.g. `theme.palette.primary`), unique per section. `validators` references reusable validator names (`hexColor`, `url`, `email`, `json`, `css`, `localeCompleteness`, `duplicateRoutes`, `widgetConfig`) rather than embedding logic.
|
||||
- `editor-schema.ts` — `SECTION_FIELD_SCHEMAS`: every editable field, one entry per section, sourced from what each template already renders. `ALL_FIELD_SCHEMAS` flattens it.
|
||||
- `editor-schema.service.ts` (`EditorSchemaService`, `providedIn: 'root'`) — `getFields(section)`, `getField(key)`, `all()`, `getByPath(source, key)` (safe dot-path resolver, never throws on a missing segment).
|
||||
|
||||
The schema is currently consumed by the facade (validation issue → field mapping, modified-field diffing, change-summary labels), not by the templates directly — templates keep calling `facade.updateBootstrap()` the same way they always did.
|
||||
|
||||
### Centralized validators (`schema/validators/`)
|
||||
|
||||
`primitives.ts` holds pure, framework-free functions — one per concern, reused everywhere that concern appears: `isValidHexColor`, `isValidHttpUrl`, `isValidEmail`, `validateJson`, `validateCss` (brace-balance check, comments stripped), `extractStyleBlocks` (pulls `<style>` bodies out of static-page HTML), `normalizeRoute` (trim/strip-slashes/lowercase for duplicate comparison).
|
||||
|
||||
`ProjectValidator` (`services/project-validator.service.ts`) composes these primitives into checks and tags every `ProjectValidationIssue` with `section`, `fieldKey`, and `severity` (`'error'` blocks Publish, `'warning'` is advisory). Checks: missing `branding.logoUrl`, no supported locales, **default locale not itself in the supported-locales list** (error — catches General's free-text default-language field pointing at an unsupported code), invalid `tenant.websiteBaseUrl`, duplicate static-page slugs, **duplicate routes** across `pages`/`staticPages` (warning), empty homepage, a homepage widget with no `type`, **malformed widget config** — missing `id`/`type`/`version`/`props` (error), duplicate header nav links, invalid theme colors, **invalid CSS** inside static-page `<style>` blocks (warning), missing translations for a supported locale, layout/section-layout values outside the known enums, **invalid company contact email** (error), **invalid footer social-link URL** (warning), and **incomplete footer payment icon** — only one of `src`/`alt` set (warning).
|
||||
|
||||
### Live inline feedback (facade)
|
||||
|
||||
`ProjectEditorFacade` exposes, on top of `validationIssues`: `blockingIssues` / `hasBlockingIssues` (severity-filtered), `issuesByField: Map<string, ProjectValidationIssue[]>`, `issuesBySection: Map<ProjectEditorSectionId, number>`, and `fieldError(key)` (first message for a field, or `null`). `publish()` gates on `hasBlockingIssues()`, not "any issue" — a duplicate-route or invalid-CSS warning no longer blocks publishing. Sections bind `[error]` on `app-form-field` (or a standalone `<p class="editor-error">` where the target isn't a single form-field, e.g. a whole list) for every field that has a matching `ProjectValidator` `fieldKey` today: theme palette, general name/domain/default-locale, branding logo, languages (`localization.supportedLocales`), homepage/widgets (`pages`), navigation (`navigation.header`), static-pages (`staticPages`), and footer (contact email, social links, payment icons). Header/features have no matching validator checks (every field there is a bool/enum, always valid by construction), so nothing is wired there — not an oversight. `project-editor-nav` shows a red badge with the blocking-issue count per section.
|
||||
|
||||
### Undo / redo (`schema/history.util.ts` + facade)
|
||||
|
||||
A pure, framework-free reducer (`emptyHistory`, `commit`, `undo`, `redo`) over immutable `BootstrapConfig` snapshots, capped at 50 entries. The facade wraps it with **debounced commits** (~300ms): `updateBootstrap()` captures the pre-burst snapshot on the first call in a burst and only pushes it to history once edits settle, so a run of rapid typing collapses into one undo step instead of one per keystroke. `undo()`/`redo()` route through the same draft-save path as every other mutation, so the `localStorage` autosave never desyncs from the in-memory undo stack. History is cleared on `loadBootstrap()`, `publish()`, and `resetDraft()` (a fresh baseline invalidates old snapshots). UI: save-bar Undo/Redo buttons (`canUndo`/`canRedo`), `Ctrl/Cmd+Z` / `Ctrl/Cmd+Shift+Z` / `Ctrl/Cmd+Y` page-level shortcuts (skipped while a text field has focus, so native per-field text undo still works).
|
||||
|
||||
### Modified-field tracking
|
||||
|
||||
`modifiedFields` (facade, `computed<Set<string>>`) diffs every schema field's current value against `originalBootstrap`. `modifiedSections` rolls that up per section for an amber dot in the nav (shown only when a section has no blocking-issue badge). `changeSummary` builds the before/after rows (schema label + stringified value, truncated for objects) consumed by the Preview section below.
|
||||
|
||||
### Pre-publish preview
|
||||
|
||||
The Preview tab (`preview-section`) now opens with a "changes since last publish" card: the full validation issue list (warning/error styled) plus a before/after table from `changeSummary`, ahead of the existing export/import/live-preview card. Reuses the existing `ProjectEditorPreviewService.preview()` — no new preview mechanism, just more visibility before triggering it.
|
||||
|
||||
### What stayed the same
|
||||
|
||||
No changes to `ProjectEditorIoService` (export/import), `ProjectEditorDraftStorageService` (draft `localStorage` format), or the publish/draft/reset flow described above — draft/publish/import/export compatibility is fully preserved. Section templates are unchanged except for `[error]` bindings on already-existing `app-form-field` usages.
|
||||
|
||||
## Admin Authentication (QR reuse)
|
||||
|
||||
Admin login shares the exact same Telegram QR/session backend and `TelegramLoginComponent` as customer login (`mode: 'admin'` vs `'customer'`) — only the cookie name/`SameSite` policy, token storage keys, and guard differ. **Backend gap:** because both flows hit the same session endpoint, the backend cannot distinguish an admin scan from a customer scan today — real admin authorization must be enforced server-side. Full detail: `docs/BACKEND.md` §4 Authentication and §5 Security (permission matrix).
|
||||
|
||||
## Design system primitives (post-Sprint 30 redesign)
|
||||
|
||||
All 11 section components (`sections/*.component.html`) share these 6 `shared/ui/*` primitives instead of copy-pasted markup. They mirror the existing `InputComponent` CVA idiom (standalone, `OnPush`, `NG_VALUE_ACCESSOR` where they're form controls):
|
||||
|
||||
| Primitive | Selector | Replaces | Used in |
|
||||
|---|---|---|---|
|
||||
| `ToggleComponent` | `app-toggle` | raw `<input type="checkbox">` + `$any($event.target).checked` | header, homepage, widgets, navigation, features |
|
||||
| `SelectComponent` | `app-select` | raw `<select>` | theme (`theme.mode`, `layout.type`) |
|
||||
| `ColorPickerComponent` | `app-color-picker` | raw `<input type="color">` | theme (8 palette colors) |
|
||||
| `SectionCardComponent` | `app-section-card` | the copy-pasted `editor-section-card`/`<h2>` shell | all 11 sections |
|
||||
| `LocaleTabsComponent` | `app-locale-tabs` | (new capability) | languages, navigation |
|
||||
| `KeyValueEditorComponent<T>` | `app-key-value-editor` | pipe-delimited `<textarea>` lists | footer (payment icons, social links) |
|
||||
| `ImageFieldComponent` | `app-image-field` | manual URL `<input>` + separate "choose image" button, no preview | branding (logo/small-logo/favicon/social/gallery), footer (logo, payment icons) — thumbnail preview + Replace/Remove, opens its own `app-media-picker` |
|
||||
| `CodeEditorComponent` | `app-code-editor` | plain `<textarea>` for raw-HTML mode | `MarketplaceHtmlEditorComponent`'s "Код" toggle — overlay-textarea syntax highlighting (no Monaco/CodeMirror dependency); tokenizes HTML tags/comments and delegates `<style>` block contents to a CSS tokenizer (selector/property/value/string/comment-aware) |
|
||||
|
||||
`section.shared.scss`'s `.editor-grid.*` classes are unchanged and still used inside `SectionCard` bodies; only the outer `.editor-section-card` shell and per-section `<h2>` were replaced (that rule has been removed from the shared stylesheet since it has no remaining consumers).
|
||||
|
||||
## Interaction / motion pass (2026-07-16)
|
||||
|
||||
Interaction feedback + motion applied consistently, all gated behind `prefers-reduced-motion`:
|
||||
|
||||
- `section.shared.scss` `button`/`button.secondary` gained hover/active/`focus-visible`/`disabled` states (previously flat, no feedback across all 11 sections).
|
||||
- `project-editor-page.component.scss`: the active section fades/slides in (220ms) when the `@switch` swaps components; the "reset section" button got matching hover/focus states.
|
||||
- `project-editor-save-bar` buttons now use the shared `app-button` primitive (danger / secondary / primary variants) instead of unstyled native `<button>`s.
|
||||
|
||||
## HTML editor (Static Pages)
|
||||
|
||||
`MarketplaceHtmlEditorComponent` (`components/html-editor/marketplace-html-editor.component.ts`) is a `contenteditable` WYSIWYG used inside the Static Pages editor (`features/content-management/.../static-pages-editor.component.html`), one instance per locale, bound `[html]` / `(htmlChange)`.
|
||||
|
||||
**Status: working.** Verified live 2026-07-16 — typing captured, toolbar commands functional (bold toggles, `insertUnorderedList` wraps `<ul><li>`, H2/H3/link/image/table), `htmlChange` emits on every edit, and a "Код" toggle swaps to raw-HTML editing.
|
||||
|
||||
**Toolbar (Sprint X+2 additions):** horizontal rule, code block (`<pre>`), embed (prompt for a URL, inserts a sandboxed `<iframe sandbox="allow-scripts allow-same-origin">`) — alongside the original bold/italic/underline/H2/H3/lists/link/image/table set.
|
||||
|
||||
**HTML-mode validation (Sprint X+2):** switching from raw-HTML back to the visual surface now runs `schema/validators/primitives.validateHtml` (stack-based tag-balance check) first; a malformed edit (unclosed/mismatched tag) stays in code mode with an inline error instead of silently corrupting the visual editor.
|
||||
|
||||
**Caveat:** implemented on the deprecated `document.execCommand` API. It works in all current browsers today but is a legacy web API with no modern drop-in replacement; if a future browser drops it, this component needs a rewrite (e.g. a maintained rich-text library). By design it emits **raw, unsanitized** HTML — sanitization is a storefront-render concern, not an authoring one (see `docs/BACKEND.md` §3 CRUD Contracts, CMS, on server-side content moderation on publish; `StaticPageComponent` and `StaticPagePreviewComponent` both run content through `DomSanitizer` before render).
|
||||
|
||||
## Field-description / dropdown UX (Sprint 19+)
|
||||
|
||||
Every field across the 10 editor section templates now carries a one-line, i18n'd description under its label explaining what it does in plain language (all new copy routed through `TranslateService`/`TranslatePipe`, added to `Translations` + `en.ts`/`ru.ts`/`hy.ts` following the existing `builder.*` key pattern — see `src/app/i18n/translations.ts`).
|
||||
|
||||
**Converted from free-text `<input>` to `<select>`** (backed by a closed TypeScript union), each option carrying a human label and a short description (via `title` attribute) instead of the raw enum value:
|
||||
|
||||
- `section.layout.strategy` (Homepage section) — `SectionLayoutStrategy`: `stack | grid | hero | carousel | split`.
|
||||
- `theme.mode` (Theme section) — `light | dark | system`.
|
||||
- `layout.type` (Theme section, "Site Layout") — `PlatformLayoutType`: `default | sidebar-left | carousel-home | minimal`.
|
||||
- `catalog.navigationMode` (Marketplace Features section) — `CatalogNavigationModeConfig`: `default | left-category-navigation | mega-category-layout | top-category-carousel`.
|
||||
|
||||
Each of these components defines a local `readonly` options array of `{ value, labelKey, descriptionKey }` (per ADR-006, these are section/container components so this is allowed without a new shared UI library).
|
||||
|
||||
**Still plain text/checkbox, with a description added, and why:** marketplace name, domain, description, logo/favicon/small-logo URLs, palette colors (already `<input type="color">`, which is the correct native widget), company/address/phone/email, copyright, payment icons/social links (JSON-ish textarea), homepage section `columns` (a number, not an enum), widget-specific props (`hero`/`categories`/`product-collection` typed fields like layout/height/overlay/autoplay/cardsPerRow — these are widget `props` strings/booleans, not modeled as TypeScript unions anywhere, so they stay free text/checkbox with a description rather than a fabricated enum), navigation link label/URL, and the widget JSON fallback textarea for any widget type without a dedicated editor. These are genuinely open-ended or already have the correct native input type; converting them to `<select>` would either be wrong (URLs/colors/free text) or invent an enum that doesn't exist in the schema.
|
||||
|
||||
## Bug-hunt audit pass (2026-07-17)
|
||||
|
||||
A section-by-section correctness audit (not a feature pass) — for each section, checked whether its controls actually do what they claim at runtime, not just whether they render. 9 real, verified defects found and fixed (each confirmed live via `window.ng.getComponent()` reproducing the exact bug, then re-verified fixed):
|
||||
|
||||
- **Footer**: `createSocialLinkRow`'s id was derived from array length (`social-${length+1}`) — add/remove/add reliably collides with a surviving row's id, corrupting `footer.component.html`'s `@for (... track item.id)` DOM identity on the public storefront. Payment-icon `@for` tracked by `icon.src`, which collides whenever two rows share a src (most commonly two blank ones). Both switched to safe keys.
|
||||
- **Features**: wishlist/compare visibility is gated by *two* flags at runtime (`featureFlags.<key>` AND `userExperience.<key>.enabled` — see `feature-config.service.ts`), but the editor only exposed a toggle for the first. Both default `true` so it was silent, but a config with the second explicitly `false` left the toggle looking "on" with no way to fix it from this screen. Now one toggle drives both.
|
||||
- **Widgets**: the JSON-fallback textarea's `updateJson()` caught parse errors and did nothing, but the textarea was bound to `propsJson(committed props)` — so an in-progress invalid edit got silently overwritten on the next change-detection pass. Now keeps the user's draft on screen with an inline error until it's valid.
|
||||
- **Languages**: `addLocale()` cleared the input regardless of whether `LocaleSyncService` actually accepted the code — adding an already-supported locale silently no-opped. Now shows an inline error and leaves the input untouched.
|
||||
- **Preview**: `importBootstrap()` replaced `state.bootstrap` directly instead of routing through `updateBootstrap()` — so an import never got a `draftStorage.save()` (lost on refresh before an explicit Save) and was never an undo-able history step. Now routed through the same pipeline as every other edit.
|
||||
- **Static Pages**: `createPage()`'s slug (`custom-page-${length+1}`) and `duplicatePage()`'s slug/route (fixed `-copy` suffix) both reproducibly collide the same way as the footer bug above (create/delete/create; duplicate the same page twice). Added a shared `uniqueValue()` helper (appends `-2`, `-3`, ... until free).
|
||||
- **General**: "Supported Languages" is a free-text comma list that bypassed `LocaleSyncService` entirely, so adding a locale here never seeded the empty translation entries Languages' add-button produces — the two UI paths silently diverged. Now diffs and routes through `facade.addLocale()`/`removeLocale()`. Also added the "default locale not itself supported" validator check described above, since this field had (and still has, by design — it's free text) no format guard.
|
||||
- **Branding → SEO**: `branding.socialImageUrl` (added earlier this same pass) wasn't actually read by `SeoService.resetToDefaults()` — the OG/Twitter image fallback stayed on `appIconUrl || logoUrl`. Fixed to check it first.
|
||||
- **Media picker**: `MediaLibraryFacade` is a root-provided singleton shared by *every* `app-media-picker` instance on a page (branding alone renders 4). `ngOnInit` loaded unconditionally on mount regardless of dialog state, and `search`/`folder`/`page` filters leaked between independently-opened picker dialogs. Replaced with an `effect()` that resets those filters and loads only when that instance's own `open` input actually becomes `true`.
|
||||
|
||||
### Known gaps found but not fixed (real, out of scope for this pass)
|
||||
|
||||
- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix.
|
||||
- **`layout.type` (Site Layout) and the homepage section's `type` field both feed a rendering pipeline that was never wired up.** `src/app/dynamic-renderer/` has services/models for page/section/widget rendering but zero components or templates (every directory has only a `.gitkeep`) — the storefront homepage renders through a separate, older path that ignores both fields. `homepage-section.component.ts`'s `updateSection(id, 'type', ...)` has no UI calling it because of this; not built, since building UI for a field nothing reads would be inventing dead controls.
|
||||
- **`HeaderConfig.showProfile`** is a real toggle in `header-section` with no corresponding profile/account menu anywhere in `header.component.html` — the toggle currently does nothing. Building the actual menu is a feature (needs an auth-system check first), not an editor-wiring fix.
|
||||
392
docs/FORK-ANALYSIS-2026-08-21.md
Normal file
392
docs/FORK-ANALYSIS-2026-08-21.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Fork Analysis — `marketplaces-main.zip` (hub.numus.cc/numus/marketplaces)
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Artifact analysed:** `C:\Users\darbi\Downloads\marketplaces-main.zip` (4.48 MB, 13 MB extracted)
|
||||
**Analysed against:** this repo, branch `B2B`, HEAD `92f1c88`
|
||||
**Canonical repo of the archive:** `ssh://git@hub.numus.cc:2222/numus/marketplaces.git`, tag `handoff-baseline-2026-08-11`
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict in five lines
|
||||
|
||||
1. This is **not a fork of our repo**. It is a **separate monorepo** — NestJS backend + PostgreSQL + two Angular apps + real infrastructure — that shares an *older* common ancestor with us (`dexarmarket`, Angular 21.2.18).
|
||||
2. They **received our code on 11 Aug 2026**, audited it, and parked it verbatim under `reference/parallel-frontend/` with a SHA-256 fingerprint. They explicitly ruled it **not production**, and wrote a document listing what they will and will not take from us.
|
||||
3. They are ahead of us in exactly one dimension, and it is the decisive one: **they have a backend, a database, RBAC, payments, tenancy by Host, publish/rollback revisions, and a deploy runbook that exists.** We have contracts describing all of that and 22 mock gateways.
|
||||
4. We are ahead of them in exactly one dimension, and they admit it in writing: **frontend depth and editor UX** (530 `.ts` vs 189, 158 components, 30 spec files, boundary checker, Angular 22). Their backoffice is 45 files and still ships `mock-data.service.ts`.
|
||||
5. **VK and Yandex login do not exist in their code.** Zero references, backend and frontend. Section 8 covers what they actually have and gives the design to add VK ID + Yandex ID on our side.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the archive actually contains
|
||||
|
||||
```
|
||||
marketplaces/
|
||||
├── platform-api/ NestJS 11 + Fastify + Prisma + PostgreSQL 17 (44 .ts, ~4 800 LOC)
|
||||
├── backoffice/ Angular 21.2.18 admin + order-manager portal (45 .ts)
|
||||
├── marketplaces/ Angular 21.2.18 runtime storefront (189 .ts)
|
||||
├── infra/ Docker Compose, Nginx, backup, domain automation (21 files)
|
||||
├── docs/ 11 canonical documents, ~1 090 lines, Russian
|
||||
└── reference/
|
||||
└── parallel-frontend/ ← OUR REPO, verbatim, 791 files
|
||||
```
|
||||
|
||||
### 1.1 Lineage — read this carefully
|
||||
|
||||
- `marketplaces/package.json` is named **`dexarmarket`**, Angular **21.2.18**, with brand configs `dexar` / `novo` / `lavero`.
|
||||
- Our `package.json` is named **`dexarmarket`**, Angular **22.0.8**.
|
||||
- `reference/parallel-frontend/package.json` is **our current code**, Angular 22.0.8, with our `arch:check` scripts.
|
||||
|
||||
Common ancestor. They branched earlier and went backend-first; we stayed frontend and went deep. `reference/parallel-frontend/SOURCE_MANIFEST.md` records:
|
||||
|
||||
> Source: `marketplaces-main.zip`, received 11 August 2026. SHA-256 `4d2d990416f573791b09df9ca8c02266432b5ddf213b9ed38c0bced1f19d854f`. 745 files in `src/`, 26 in `public/`, 1 in `tools/`.
|
||||
|
||||
They stripped our sprint reports and internal task docs and replaced them with their own summary. Our source they copied unchanged.
|
||||
|
||||
### 1.2 They are not "overtaking us" — they were handed the platform mandate
|
||||
|
||||
`docs/DEVELOPER_HANDOFF.md` is a **handover-to-a-new-developer document**, with a priority ladder that puts our work last:
|
||||
|
||||
> 1. Security, tenant isolation and financial correctness.
|
||||
> 2. `docs/PRODUCT_SPECIFICATION.md`.
|
||||
> 3. Real models and invariants of `platform-api`.
|
||||
> 4. Existing confirmed production scenarios.
|
||||
> 5. **UI/UX patterns of the parallel implementation.**
|
||||
|
||||
That is the political read: our repo has been reclassified from "the product" to "the design reference". Everything below assumes we want that reversed or renegotiated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory comparison
|
||||
|
||||
| | **Them (archive)** | **Us (`B2B` @ 92f1c88)** |
|
||||
|---|---|---|
|
||||
| Backend | NestJS 11 / Fastify, 44 files, running | None. 17 contract docs in `docs/backend/` |
|
||||
| Database | PostgreSQL 17, Prisma, 36 models, 3 migrations | None |
|
||||
| Storefront | Angular 21.2.18, 189 `.ts` | Angular 22.0.8, 530 `.ts`, 158 components |
|
||||
| Backoffice | Angular 21.2.18, 45 `.ts`, still mock-backed | 14 admin modules, 22 local + 22 API gateway pairs |
|
||||
| Auth (admin) | Email + Argon2id + mandatory TOTP, HttpOnly cookie, server sessions | `admin-auth.guard.ts` + dev bypass |
|
||||
| Auth (customer) | Telegram QR via external `USERAUTH_API_URL`, server session, HttpOnly cookie | Telegram, client-side |
|
||||
| RBAC | 5 roles enforced server-side + per-marketplace membership | Client-side permission model |
|
||||
| Payments | Vitanova + NUMUS adapters, encrypted per-tenant credentials, HMAC webhooks, idempotency, poll fallback | FX/pricing gateways, payment contracts, no server |
|
||||
| Multi-tenancy | Host → verified `MarketplaceDomain` → tenant, 30 s cache, 404 on unknown | Bootstrap-driven runtime config |
|
||||
| Publish | Immutable `MarketplaceRevision` snapshots, atomic publish, rollback-as-new-revision | Draft/publish UI, local persistence |
|
||||
| Infra | Compose (internal + egress networks), Nginx templates, certbot, WAL archiving, backup timers, restore check, fail2ban, sysctl/ssh hardening | `scripts/deploy/*.sh`, GH Actions deploy, wildcard TLS |
|
||||
| Tests | 9 spec files, 25 tests total, **no e2e at all** | 30 spec files, Playwright e2e, coverage floor in CI |
|
||||
| Arch governance | None | `check-boundaries.mjs`, madge cycles, `architecture-governance.yml` |
|
||||
| Bundle | storefront 648 kB (48 kB over) | 1.15 MB (452 kB over a 700 kB budget) — *their measurement of us* |
|
||||
|
||||
---
|
||||
|
||||
## 3. Their backend, in detail — the part worth studying
|
||||
|
||||
### 3.1 Tenant resolution (`common/tenant.service.ts`)
|
||||
|
||||
- `normalizeHost()` lowercases, strips trailing dot, strips port.
|
||||
- Looks up `MarketplaceDomain` by `hostname` **unique index**, requires `verifiedAt != null`, requires `status = ACTIVE`.
|
||||
- 30-second in-process cache keyed by hostname, with `invalidate(hostname?)`.
|
||||
- Unknown host → `404`, never a fallback tenant.
|
||||
- **Preview:** HMAC-signed token `base64url(payload).base64url(hmac)` carrying `{marketplaceId, expiresAt, nonce}`, 15 min TTL, delivered as a `storefront_preview` cookie. A global Fastify `onRequest` hook returns `404 Preview mode is read-only` for any non-GET on `/api/v1/*` while that cookie is present. Cheap, clean, and something we do not have.
|
||||
|
||||
### 3.2 Admin auth (`auth/admin-auth.service.ts`)
|
||||
|
||||
- Argon2id (`memoryCost 65536, timeCost 3, parallelism 1`), TOTP **mandatory** — first login without TOTP returns a signed 10-minute `setupToken` + `otpauth://` URI and refuses to issue a session until TOTP is confirmed.
|
||||
- Sessions are random 32 bytes, stored as **SHA-256 hash only**, 12 h TTL, with `ipAddress` + `userAgent`.
|
||||
- `authenticate()` rejects if `revokedAt`, expired, user inactive, **or TOTP not enabled**.
|
||||
- Password change requires ≥16 chars and revokes every live session in the same transaction.
|
||||
- Role weights: `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`; `hasAccess()` checks weight **and** marketplace scope.
|
||||
- Manager portal uses a **separate cookie** (`manager_session`) and a separate guard, pinned to one marketplace slug.
|
||||
|
||||
### 3.3 CSRF / origin control (`main.ts`, `auth/admin-origins.ts`)
|
||||
|
||||
A global hook rejects any non-GET on `/api/admin/*` or `/api/manager/*` whose `Origin` header is not in `ADMIN_ORIGIN` + `ADMIN_ORIGINS`. CORS `origin` is the same allowlist with `credentials: true`. Rate limit 120/min per IP; multipart capped at 1 file / 10 MB / 4 fields; body limit 12 MB; `trustProxy: true`; global `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`.
|
||||
|
||||
### 3.4 Checkout (`checkout/checkout.service.ts`) — the atomicity pattern
|
||||
|
||||
```sql
|
||||
UPDATE "MarketplaceInventory"
|
||||
SET "reserved" = "reserved" + $qty, "updatedAt" = NOW()
|
||||
WHERE "marketplaceId" = $mp AND "variantId" = $variant
|
||||
AND ("onHand" - "reserved") >= $qty
|
||||
RETURNING "id"
|
||||
```
|
||||
|
||||
Empty result → `409 Insufficient stock`. This single conditional UPDATE inside a Prisma transaction is the whole oversell defence: no read-then-write race, no advisory locks. Then a `StockReservation` (15 min), one `InventoryMovement` per line with `reason: 'checkout_reservation'`, and an `Order` carrying a full `productSnapshot` per item. Price comes only from the server-side snapshot; the browser's price is never read. `publicToken` is `randomBytes(24).base64url` — no sequential IDs leak. Digital codes are decrypted into the response **only** when order status is `PAID`/`PROCESSING`/`FULFILLED`.
|
||||
|
||||
### 3.5 Payments (`payments/payment.service.ts`)
|
||||
|
||||
- `Payment.idempotencyKey` is a **unique column**; a repeat POST with the same key returns the existing payment, and a key reused across a different order/marketplace → `409`.
|
||||
- Provider credentials live in `PaymentCredential.encryptedConfig`, AES-256-GCM (`v1.iv.tag.ciphertext`, base64url) with a 32-byte `FIELD_ENCRYPTION_KEY`. Decrypted only inside the service, never serialized into a response.
|
||||
- NUMUS webhook: requires `eventId`/`eventType`/`timestamp`/`signature` headers, validates the envelope (`schemaVersion === 1`), HMAC-verifies the **raw body**, then inserts into `PaymentWebhookEvent` with `@@unique([provider, eventKey])`. A Prisma `P2002` collision returns `{accepted: true, duplicate: true}` — replay is a no-op by construction, not by an `if`.
|
||||
- Vitanova webhook: same shape, per-marketplace `webhookSecret` overriding the global one, `eventKey` falling back to `sha256(rawBody)`.
|
||||
- `pollPending(50)` is the reconciliation fallback for the last 24 h; failures are swallowed so the next tick retries.
|
||||
- `checkoutUrl` passes through `safeHttpsUrl()` before it is ever returned to a browser.
|
||||
|
||||
### 3.6 Publish / revisions (`admin/admin.service.ts`)
|
||||
|
||||
`publish()` materializes the draft into a full snapshot, computes `version = max(version) + 1`, writes an immutable `MarketplaceRevision`, and flips `publishedRevision` in the same transaction. Clone copies theme/categories/offers/variants/category links and **forces inventory to zero**, drops domains/customers/orders/secrets, and creates an OWNER membership for the actor. Category cloning is a topological walk that throws `Category tree contains a cycle`.
|
||||
|
||||
### 3.7 Config validation (`common/storefront-config.ts`)
|
||||
|
||||
The section schema is validated **server-side**, per section type, with hard clamps: max 40 sections; id regex; per-type height ranges (`categoryRail 98–360`, `productRail 390–760`, hero fallback 312); colour must match `#rrggbb` or fall back; URLs accepted only if local `/path` or `https://`; product/category ID lists deduped and capped at 24 UUIDs. This is exactly the "validation engine" they praised in our editor — except theirs runs where it actually binds.
|
||||
|
||||
### 3.8 Infrastructure (`infra/`)
|
||||
|
||||
- Compose with **two networks**: `platform` (`internal: true`, no egress — Postgres lives here) and `egress` (API + worker + migrate only).
|
||||
- API bound to `127.0.0.1:3000` only. `no-new-privileges` on every service.
|
||||
- Postgres 17.7 with `wal_level=replica`, `archive_mode=on`, `archive_timeout=300`, archive command copying WAL into a backup volume.
|
||||
- `migrate` is a separate one-shot service; `api` and `worker` both `depends_on: migrate: service_completed_successfully`.
|
||||
- Healthchecks: the API container hits its own `/health/ready`.
|
||||
- systemd timers: `marketplaces-backup`, `marketplaces-domain-sync`, `marketplaces-thumbnails` (path-triggered), plus `*-healthcheck` timers per app.
|
||||
- `provision-domain.sh` refuses to run unless the domain's A record already resolves to the server IP, then certbot webroot, then a **manual** review step before Nginx reload.
|
||||
- Hardening set we do not have: `fail2ban/jail.local`, `sshd` hardening drop-in, `sysctl` hardening, `docker/daemon.json`, scoped sudoers per deploy role, `restore-check.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 4. What they wrote about us (`docs/PARALLEL_IMPLEMENTATION_AUDIT.md`)
|
||||
|
||||
Their measurements of our code, 11 Aug 2026:
|
||||
|
||||
- Production build passes on Node 24.18.1.
|
||||
- **Initial bundle ~1.15 MB against a 700 kB budget — 452 kB over.**
|
||||
- Boundary + cycle checks pass.
|
||||
- 57 unit tests pass; **5 spec files total**; "checkout, RBAC, publishing, orders and admin CRUD flows are not meaningfully covered".
|
||||
- `npm audit --omit=dev`: **0 production vulnerabilities** — better than all three of their own packages.
|
||||
|
||||
Their blocking objections:
|
||||
|
||||
| Their objection | Is it fair? |
|
||||
|---|---|
|
||||
| Mock/localStorage repositories as production implementation | **Fair.** 22 local gateways, 19 files touching `localStorage`. |
|
||||
| Publishing config through localStorage | **Fair** for the modules that still do it. |
|
||||
| Admin JWT / refresh token in localStorage | Fair as of the snapshot. |
|
||||
| Admin session cookie set by JS and readable via `document.cookie` | **Fair and serious.** |
|
||||
| Shared Telegram session for customer *and* admin | **Fair and serious.** |
|
||||
| Client-side `authorization-key`, `userid-value`, partner ID | **Fair and serious** — payment credentials in the browser. |
|
||||
| Direct call to `http://ip-api.com` from an HTTPS storefront | Fair — mixed content plus a third-party geo leak. |
|
||||
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **Fair.** Redirect targets must be backend-allowlisted. |
|
||||
| Storefront + editor + backoffice in one deployable bundle | Fair, and it is also why our bundle is 452 kB over. |
|
||||
| Hardcoded fallback regions / provider URLs / brands in components | Fair. |
|
||||
|
||||
What they said they **want** from us (their P1 list, their order): editor information architecture; the section-editor schema; the validation engine with blockers/warnings/notices; undo/redo + dirty state + change summary; device preview and per-device media; searchable product/category pickers with SKU/price/stock; the media-library interaction model; semantic design tokens; the admin IA; and **our boundary checker and ADR tooling**.
|
||||
|
||||
That list is our leverage. It is also a precise statement of which of our modules are worth hardening first.
|
||||
|
||||
---
|
||||
|
||||
## 5. Differences that matter, ranked by consequence
|
||||
|
||||
1. **Truth ownership.** Their price, stock, tenant and payment truth is server-side and provably so. Ours is a contract document. Every argument about "who is ahead" reduces to this one.
|
||||
2. **Session model.** They: server-stored, hashed, HttpOnly, revocable, TOTP-gated, separate cookies per contour. Us: client-held.
|
||||
3. **Idempotency.** They: unique constraints doing the work (`Payment.idempotencyKey`, `PaymentWebhookEvent(provider,eventKey)`). Us: zero `idempot*` anywhere in the codebase.
|
||||
4. **Deployability.** They: Compose + migrations + healthchecks + WAL + restore check + domain automation. Us: shell scripts and GH Actions, with no database to migrate.
|
||||
5. **Frontend depth.** Us: 2.8× their storefront file count, 158 components, dynamic renderer, widget system, theme system, i18n, 30 spec files, Playwright e2e, boundary governance. Them: a 45-file backoffice with `mock-data.service.ts` still in it.
|
||||
6. **Test posture.** They have **no e2e whatsoever** and 25 unit tests across the whole platform. Their own `VERIFICATION.md` says the count "is insufficient to conclude production readiness". We have e2e plus a CI coverage floor. This is a real gap on their side and worth naming out loud.
|
||||
7. **Framework currency.** We are on Angular 22 / TS 6.0.3; both of their apps are on 21.2.18 with 7–8 fixable high findings in production dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 6. What we should take — concrete, ordered
|
||||
|
||||
### P0 — take these regardless of how the org question resolves
|
||||
|
||||
1. **Conditional-UPDATE stock reservation.** Adopt the pattern verbatim in `backend/BACKEND-INTEGRATION.md`: reserve via `WHERE (onHand - reserved) >= qty RETURNING id`, empty result = 409. It removes a whole class of race conditions and it is one line of SQL.
|
||||
2. **Idempotency as a unique constraint, not application logic.** `Payment.idempotencyKey UNIQUE`, `PaymentWebhookEvent @@unique([provider, eventKey])`, P2002 → `{duplicate: true}`. Push this into `backend/BACKEND-INTEGRATION.md` as a schema requirement, not a behavioural note.
|
||||
3. **Move every credential out of the browser.** Their audit is right about `authorization-key` / `userid-value` / partner ID. Mirror `PaymentCredential.encryptedConfig` (AES-256-GCM, versioned `v1.iv.tag.ct`) in our contract and delete the client-side header path.
|
||||
4. **HttpOnly server sessions, separate cookie per contour** (`bo_session`, `manager_session`, `marketplace_session`). Kill the JS-set cookie and the shared Telegram session for admin + customer. This is our single worst finding in their audit.
|
||||
5. **Origin allowlist hook for all admin mutations.** Twelve lines in `main.ts`; kills CSRF for cookie-authenticated mutations. Mirror in `backend/BACKEND-INTEGRATION.md`.
|
||||
6. **Backend-side config validation.** Our validation engine is better than theirs, but it runs in the browser. The server must re-run it. Their clamp-and-fallback ergonomics are right: never reject a colour, clamp it; never accept a non-https URL, blank it.
|
||||
|
||||
### P1 — take into our own architecture
|
||||
|
||||
7. **Signed preview token + read-only preview enforcement.** HMAC token, 15 min, `storefront_preview` cookie, global hook rejecting non-GET. We have preview UI and no preview safety.
|
||||
8. **Immutable revisions with `version = max+1`, rollback-as-new-revision.** Never rewrite history; `publishedRevision` is an integer pointer flipped in-transaction.
|
||||
9. **Clone semantics.** Copy design + catalog assignments, force inventory to 0, never copy domains/customers/orders/secrets. Their topological category walk with cycle detection is worth copying line for line.
|
||||
10. **`MarketplaceAuthCredential` table.** They have it and do not use it. It is exactly the right home for per-tenant VK/Yandex OAuth app credentials — see §8.
|
||||
11. **Two-network Compose split** (`internal: true` for the data network) and API bound to loopback. Makes "the database is not reachable from the internet" structural rather than a firewall promise.
|
||||
12. **WAL archiving + `restore-check.sh` + a scheduled restore drill.** We have deploy automation and no proven restore.
|
||||
|
||||
### P2 — process, not code
|
||||
|
||||
13. Their **`DEVELOPER_HANDOFF.md` §7 "inviolable invariants"** list is a better acceptance gate than anything currently in our delivery plan. Nine lines, each falsifiable. Adopt it as the header of our own handoff doc.
|
||||
14. Their **PR policy**: one functional area per PR; mandatory purpose, screenshots, API changes, migrations, test evidence, security impact, rollback plan; never change payment/inventory/order state machines inside a redesign PR.
|
||||
15. Their **status discipline**: "a local build or the existence of a UI does not mean production readiness". Every release records version, migration, healthcheck, smoke, audit, rollback.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ideas worth stealing (product-level)
|
||||
|
||||
- **`ORDER_MANAGER` as a fully separate contour** — separate URL, separate shell, separate cookie, separate login, pinned to one marketplace, cannot see catalog/design/domains/payment settings. Genuinely good product thinking: the people who touch orders all day are not admins, and giving them their own small app removes an entire permissions surface.
|
||||
- **`FulfillmentMode: MANUAL | CODE_POOL` + a `DigitalCode` pool** with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, and codes revealed only after payment. We have no digital-goods story at all; this is a complete one in one table.
|
||||
- **Marketplace status machine** `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`, with `DOMAIN_PENDING` as a real state rather than an error condition.
|
||||
- **Per-tenant delivery options priced in minor units on the offer**, validated at checkout ("select a delivery option for each physical product").
|
||||
- **`InventoryMovement` as an append-only journal** with `reason`, `referenceType`, `referenceId`, `actorId` — every stock change explainable after the fact. This directly answers the "we do not trust your numbers" complaint in the v3.1 plan.
|
||||
- **CSV marketplace import** (`POST /marketplaces/import`, `dryRun` default true) — bulk tenant creation as a first-class operation.
|
||||
- **Their §22 acceptance scenarios** (15 of them) are a ready-made e2e suite. Scenario 3 (two concurrent purchases of the last unit) and scenario 10 (replayed webhook) are the two tests that would catch the most expensive possible bugs. Write those two this sprint regardless of anything else in this document.
|
||||
|
||||
---
|
||||
|
||||
## 8. VK ID and Yandex login — what is actually there, and how we add it
|
||||
|
||||
### 8.1 Finding: they do not have it
|
||||
|
||||
Exhaustive search of the archive (`*.ts`, `*.html`, `*.md`, `*.json`, `*.prisma`, `*.sql`, `*.yml`, `*.conf`, env examples), excluding `node_modules` and excluding our own code under `reference/`:
|
||||
|
||||
- `vk` / `vkontakte` / `vkid` — **0 hits** in source. The only matches anywhere are inside `package-lock.json` integrity hashes and two Armenian/English FAQ content pages.
|
||||
- `yandex` — **0 hits** in source; the same two content pages only.
|
||||
- `oauth` — **0 hits** in their code. The single `oauth`-adjacent file in the whole archive is **ours**: `reference/parallel-frontend/src/app/core/auth/services/auth.service.ts`.
|
||||
- The Prisma schema has **no** `ExternalIdentity`, no `provider` column on `Customer`, and no social tables. Customer identity is `@@unique([marketplaceId, telegramUserId])` — Telegram only.
|
||||
|
||||
**Their only customer login is Telegram**, and it is not even self-hosted: `CustomerAuthService` proxies to an external service at `USERAUTH_API_URL` (`https://users.vitanova.network:456`), creates a web session, polls `/users/sessions/{id}` until `status` is confirmed, then upserts a `Customer` and issues its own 30-day session cookie.
|
||||
|
||||
So there is nothing to copy from them here. But their **session-issuing half is the right shape**, and it is what VK/Yandex should terminate into.
|
||||
|
||||
### 8.2 What we already have
|
||||
|
||||
| File | State |
|
||||
|---|---|
|
||||
| [vk-id-gateway.interface.ts](src/app/core/identity/services/vk-id-gateway.interface.ts) | `getAuthorizeUrl()`, `completeCallback(code, codeVerifier)` |
|
||||
| [vk-id-api.gateway.ts](src/app/core/identity/services/vk-id-api.gateway.ts) | Real HTTP client → `/api/identity/v1/vk/authorize`, `/vk/callback` |
|
||||
| [vk-id-local.gateway.ts](src/app/core/identity/services/vk-id-local.gateway.ts) | Mock |
|
||||
| [vk-id-gateway.token.ts](src/app/core/identity/services/vk-id-gateway.token.ts) | DI seam via `environment.useMockData` |
|
||||
| [vk-id-login.component.ts](src/app/components/vk-id-login/vk-id-login.component.ts) | Button component |
|
||||
| [customer-identity.model.ts](src/app/core/identity/models/customer-identity.model.ts) | `ExternalIdentityProvider = 'vk_id' \| 'telegram' \| 'max'` |
|
||||
| [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md) | §2 defines the VK ID contract |
|
||||
|
||||
We have the scaffolding and the contract. Yandex is absent everywhere except one mention of *Yandex Market* as a possible marketplace connector in the gap analysis — a different thing entirely.
|
||||
|
||||
### 8.3 Design — one provider-agnostic social login, VK ID and Yandex ID as instances
|
||||
|
||||
**Principle (already in our contract — keep it):** the OAuth code exchange happens entirely backend-side. No client secret, no access token, and no `code_verifier` ever reaches the browser.
|
||||
|
||||
**Change to make:** our current interface passes `codeVerifier` from the client, which forces the browser to generate and store the PKCE verifier. We are a confidential client — the backend should own the verifier. Recommended surface:
|
||||
|
||||
```
|
||||
GET /api/identity/v1/{provider}/authorize
|
||||
-> 302 to the provider, OR { url } for the client to navigate to.
|
||||
Backend generates state + code_verifier and stores both in a
|
||||
short-lived HttpOnly cookie (or server-side, keyed by state),
|
||||
10 min TTL, single use.
|
||||
|
||||
GET /api/identity/v1/{provider}/callback?code=…&state=…[&device_id=…]
|
||||
-> backend validates state, exchanges code + stored verifier,
|
||||
fetches the profile, resolves/links Customer, issues the
|
||||
marketplace session cookie, 302 back into the storefront.
|
||||
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> linked providers
|
||||
```
|
||||
|
||||
`{provider}` ∈ `vk` | `yandex` (later `telegram`, `max`). One controller, one service, a per-provider strategy object. The frontend keeps exactly one gateway interface:
|
||||
|
||||
```ts
|
||||
export type SocialProvider = 'vk' | 'yandex';
|
||||
|
||||
export interface SocialIdentityGateway {
|
||||
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||
listIdentities(): Observable<ExternalIdentity[]>;
|
||||
unlink(provider: SocialProvider): Observable<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`VkIdGateway` collapses into it, `completeCallback()` disappears from the frontend entirely (the backend handles the callback and redirects), and `vk-id-login.component` becomes `social-login-button` with a provider input. Add `'yandex_id'` to `ExternalIdentityProvider` in `customer-identity.model.ts`.
|
||||
|
||||
**Provider specifics** — confirm exact parameter and scope names against the live provider docs before implementing; both providers have revised their flows recently.
|
||||
|
||||
*VK ID* — OAuth 2.1, **PKCE mandatory**, S256.
|
||||
- Authorize: `https://id.vk.com/authorize` — `client_id`, `redirect_uri`, `response_type=code`, `code_challenge`, `code_challenge_method=S256`, `state`, `scope` (typically `vkid.personal_info email phone`).
|
||||
- The callback returns a **`device_id` alongside `code`**, and it is required for the token exchange. Missing it makes every exchange fail; this is the single most common VK ID integration bug.
|
||||
- Token: `POST https://id.vk.com/oauth2/auth` — `grant_type=authorization_code`, `code`, `code_verifier`, `device_id`, `client_id`, `redirect_uri`.
|
||||
- Profile: `POST https://id.vk.com/oauth2/user_info` with the access token → stable `user_id`, name, optional email/phone.
|
||||
- Logout: `https://id.vk.com/oauth2/logout` — call it on unlink so the provider session is not left dangling.
|
||||
|
||||
*Yandex ID* — OAuth 2.0, PKCE supported; use it.
|
||||
- Authorize: `https://oauth.yandex.ru/authorize` — `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`.
|
||||
- Token: `POST https://oauth.yandex.ru/token` — `grant_type=authorization_code`, `code`, `code_verifier`, HTTP Basic auth with `client_id:client_secret`.
|
||||
- Profile: `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <access_token>` → `id` (stable), `login`, `default_email`, `default_phone`, `psuid`, avatar id.
|
||||
- Yandex returns an email in most cases; VK often will not. Do not make email a required field on `Customer`.
|
||||
|
||||
**Data model** — add to whatever schema we land on. Their `MarketplaceAuthCredential` is the right precedent for the credentials half.
|
||||
|
||||
```prisma
|
||||
model ExternalIdentity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
customerId String @db.Uuid
|
||||
provider String // 'vk_id' | 'yandex_id' | 'telegram' | 'max'
|
||||
providerUserId String
|
||||
email String?
|
||||
phone String?
|
||||
displayName String?
|
||||
verifiedAt DateTime @default(now())
|
||||
lastUsedAt DateTime @default(now())
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerUserId]) // one provider account -> one customer
|
||||
@@index([customerId])
|
||||
}
|
||||
```
|
||||
|
||||
Plus, per tenant, an encrypted OAuth app config using the same envelope as their `FieldEncryptionService`:
|
||||
|
||||
```
|
||||
MarketplaceAuthCredential { marketplaceId, provider, encryptedConfig, active }
|
||||
encryptedConfig = { clientId, clientSecret, scopes[], redirectUri }
|
||||
```
|
||||
|
||||
**Five rules that decide whether this ships correctly:**
|
||||
|
||||
1. **Redirect URI vs. multi-tenant domains.** VK and Yandex both validate `redirect_uri` against an exact registered list. With N tenant domains you cannot register N URIs per app, and you cannot let tenants supply their own. Use **one central identity host** (e.g. `id.<platform-domain>`) as the only registered callback, carry the origin tenant inside the signed `state`, and 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant's API exchanges for the session cookie. Decide this before writing any code — retrofitting it is expensive.
|
||||
2. **`@@unique([provider, providerUserId])`, plus a decision on per-tenant customer separation.** Their platform isolates `Customer` per marketplace even for the same Telegram ID. Decide explicitly whether one VK account across two of our storefronts is one customer or two. Their answer is *two*; that is the safer default for data protection and the one our `Customer.marketplaceId` already implies.
|
||||
3. **Identity conflict is not an upsert.** Our PHASE-8 §2 already says this: if `providerUserId` is already bound to a different `Customer`, route to controlled resolution — never silently rebind. Enforce it with the unique index so the database refuses, rather than trusting the service layer.
|
||||
4. **`state` is single-use and bound to the browser.** Store `{ state, codeVerifier, marketplaceId, returnTo, expiresAt }` server-side or in a signed HttpOnly cookie; delete on first use. Reject unknown/expired/replayed `state` with a generic error.
|
||||
5. **The session that comes out is our normal session.** VK/Yandex end where Telegram ends: a random 32-byte token, stored as SHA-256, HttpOnly + Secure + SameSite=Lax, per-marketplace, revocable. Social login is an *entry path*, not a session format.
|
||||
|
||||
**Build order:** provider-agnostic backend endpoints + `ExternalIdentity` table → VK ID (v3.1 names it the primary social login) → Yandex ID (a second instance of the same strategy, roughly a day once VK works) → migrate Telegram onto `ExternalIdentity` so it becomes one provider among several rather than the schema's only key → account-linking UI (`/me/identities`, link/unlink) → email/phone OTP as recovery.
|
||||
|
||||
---
|
||||
|
||||
## 9. What we must not copy from them
|
||||
|
||||
- **Angular 21.2.18** with 7–8 open high findings in production dependencies, in both apps. We are on 22.0.8 with a clean production audit. Do not regress.
|
||||
- **`mock-data.service.ts` in the backoffice** — they still ship one while telling us mocks are disqualifying.
|
||||
- **25 unit tests and zero e2e.** Their own verification doc concedes this is not sufficient.
|
||||
- **`ORDER_MANAGER_MARKETPLACE_SLUG` pinned by environment variable** (default `'dexar'`, `'novo'` in the example env). Manager scope should come from membership rows, not an env string.
|
||||
- **Server IP hardcoded in `provision-domain.sh`** (`109.120.134.244`), and the `sslip.io` staging hosts baked into the committed env example.
|
||||
- Their **section schema** is narrower than ours (5 section types vs our widget system). Take their *server-side validation discipline*, not their schema.
|
||||
|
||||
---
|
||||
|
||||
## 10. Recommended next actions
|
||||
|
||||
| # | Action | Why now |
|
||||
|---|---|---|
|
||||
| 1 | Write the two e2e tests from their §22: concurrent purchase of the last unit, and a replayed webhook | Highest bug-cost coverage per hour, and they have neither |
|
||||
| 2 | Remove client-held payment credentials and JS-set admin cookies | Their audit's most serious finding, and it is correct |
|
||||
| 3 | Fold their invariant list (§7 of their handoff) into our own handoff doc as a signed acceptance gate | Turns their strongest document into our shared standard |
|
||||
| 4 | Decide the central-identity-host question in §8.3 rule 1 | Blocks VK ID, and it is a one-way door |
|
||||
| 5 | Implement provider-agnostic social identity, then VK ID, then Yandex ID | v3.1 §14 names VK ID the primary social login; nobody has it yet, including them |
|
||||
| 6 | Cut the storefront bundle below budget by splitting storefront / editor / backoffice deployables | 452 kB over, and it is the one performance criticism that is objectively measured |
|
||||
| 7 | Take the position explicitly that the two codebases merge as *their backend + our frontend* | Their handoff doc already ranks our work fifth; unchallenged, that becomes the plan of record |
|
||||
|
||||
---
|
||||
|
||||
## Appendix — where things live in the archive
|
||||
|
||||
| Concern | Path |
|
||||
|---|---|
|
||||
| Tenant by Host, preview tokens | `platform-api/src/common/tenant.service.ts` |
|
||||
| Admin auth, TOTP, RBAC weights | `platform-api/src/auth/admin-auth.service.ts` |
|
||||
| Origin allowlist / CSRF hook | `platform-api/src/main.ts`, `src/auth/admin-origins.ts` |
|
||||
| Stock reservation SQL | `platform-api/src/checkout/checkout.service.ts` |
|
||||
| Idempotency, webhooks, polling | `platform-api/src/payments/payment.service.ts` |
|
||||
| Field encryption (AES-256-GCM) | `platform-api/src/common/field-encryption.service.ts` |
|
||||
| Server-side section validation | `platform-api/src/common/storefront-config.ts` |
|
||||
| Publish / rollback / clone | `platform-api/src/admin/admin.service.ts` |
|
||||
| Customer (Telegram) sessions | `platform-api/src/storefront/customer-auth.service.ts` |
|
||||
| Data model, 36 entities | `platform-api/prisma/schema.prisma` |
|
||||
| Compose, networks, WAL | `infra/compose.yml` |
|
||||
| Domain provisioning, backup, restore check | `infra/scripts/` |
|
||||
| Host hardening (fail2ban, sshd, sysctl) | `marketplaces/infra/server/` |
|
||||
| Their audit of our code | `docs/PARALLEL_IMPLEMENTATION_AUDIT.md` |
|
||||
| Their target spec (479 lines) | `docs/PRODUCT_SPECIFICATION.md` |
|
||||
| Their handoff + invariants | `docs/DEVELOPER_HANDOFF.md` |
|
||||
| Our code, verbatim | `reference/parallel-frontend/` |
|
||||
304
docs/FORK-HARVEST-TODO.md
Normal file
304
docs/FORK-HARVEST-TODO.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Fork Harvest — TODO
|
||||
|
||||
**Branch:** `improvements/fork-harvest` (from `B2B` @ `92f1c88`)
|
||||
**Design:** [2026-08-21-fork-harvest-design.md](superpowers/specs/2026-08-21-fork-harvest-design.md)
|
||||
**Source analysis:** [FORK-ANALYSIS-2026-08-21.md](FORK-ANALYSIS-2026-08-21.md)
|
||||
|
||||
Improvements only. Nothing here regresses our Angular version, test count, or architecture governance.
|
||||
|
||||
**Effort:** S ≤ half a day · M ≤ 2 days · L > 2 days
|
||||
**Lane:** A frontend · B backend contract · C `@marketplaces/auth` package · D infra/ops · E process
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 — Decide first (blocks Wave 4)
|
||||
|
||||
- [ ] **FH-0.1 — Decide the central identity host** · L · Lane C · *blocker*
|
||||
VK ID and Yandex ID both validate `redirect_uri` against an exact registered list. We cannot register one per tenant domain, and we cannot let tenants supply their own.
|
||||
**Decision needed:** single central callback host (e.g. `id.<platform-domain>`) as the only registered URI, tenant carried inside signed `state`, 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for a session cookie.
|
||||
**Also decide:** is one VK account across two of our storefronts one `Customer` or two? Their platform says two; our `Customer.marketplaceId` already implies two.
|
||||
**Done when:** an ADR exists in `docs/context/adrs/` and both questions have a recorded answer.
|
||||
|
||||
- [ ] **FH-0.2 — Confirm the server-priced checkout path covers every live flow** · S · Lane A · *blocks FH-1.3*
|
||||
`api.service.ts` already has a server-priced checkout session method. Confirm no production flow still depends on `createPayment(payload, headers)` before deleting the header path.
|
||||
**Done when:** every caller of the legacy header path is enumerated and has a replacement.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — Live defects with a security benefit (Lane A, this sprint)
|
||||
|
||||
- [x] **FH-1.1 — Kill the plaintext third-party geo call** · S · Lane A · **done 2026-08-21**
|
||||
Now `GET {tenantApiBase}/geo/resolve`, same base as `/regions`. Server reads the client IP; nothing leaves our infrastructure. Endpoint specified in [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md) §6 — **not built yet**, and until it is the client falls back to the manual picker, which is what production has effectively had all along. Covered by `src/app/services/location.service.spec.ts` (4 tests, one of which fails the build on any off-origin or plaintext request from this service).
|
||||
*Was:* `location.service.ts:75` called `http://ip-api.com/json/?fields=…` from an HTTPS origin. Mixed active content is blocked, so `detectLocation()` only ever took its error branch — auto-detect was dead in production, not merely insecure — and the attempt still leaked every visitor's IP to a third party.
|
||||
|
||||
- [ ] **FH-1.2 — Stop blindly trusting the bank redirect URL** · M · Lane A
|
||||
`src/app/pages/cart/cart.component.ts:485` — `bypassSecurityTrustResourceUrl(bankUrl)` with no validation, rendered into a popup iframe. Most acquirer 3-D Secure pages send `X-Frame-Options: DENY`, so the popup is blank for those banks. Their spec: card checkout navigates the current tab, no intermediate popup.
|
||||
**Do:** accept only an `https:` URL whose origin the backend returned in the payment response (backend allowlist, per their `safeHttpsUrl()`); navigate the current tab instead of framing.
|
||||
**Done when:** a non-https or non-allowlisted URL is refused with a visible payment error; a test covers both the accepted and the refused case.
|
||||
|
||||
- [x] **FH-1.3 — Remove provider credentials from the browser** · M · Lane A · **landed via the `@marketplaces/payment` migration**
|
||||
The legacy payment surface on `ApiService` was deleted wholesale in that work. `grep -ri "authorization-key\|userid-value\|web-97ec" src/` now returns nothing. Keep FH-3.5 (bundle secret scan) to stop it coming back.
|
||||
*Was:* `api.service.ts:675` set `authorization-key` and `userid-value` headers client-side, and `api.service.ts:143` shipped a partner ID literal in the bundle. Their audit's most serious finding, and it was correct.
|
||||
|
||||
- [ ] **FH-1.4 — Send `Idempotency-Key` on payment creation** · S · Lane A
|
||||
Zero `idempot*` anywhere in our codebase. Their API requires the header and rejects a key reused across a different order.
|
||||
**Do:** generate one key per checkout attempt, stable across retries and across a double-click, sent on payment creation.
|
||||
**Done when:** the existing `checkout-idempotent-click.spec.ts` asserts both requests carry the *same* key.
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — Contract hardening (Lane B, parallel with Wave 1)
|
||||
|
||||
Each item is normative text plus an acceptance scenario in `backend/BACKEND-INTEGRATION.md`, so it becomes a delivery gate rather than a wish.
|
||||
|
||||
- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.1 — the conditional `UPDATE … WHERE (available - reserved) >= qty RETURNING id`, 409 on zero rows, whole-cart rollback, 15 min TTL.
|
||||
`UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request.
|
||||
**Acceptance:** two concurrent purchases of the last unit produce exactly one payable order.
|
||||
|
||||
- [x] **FH-2.2 — Idempotency as unique constraints** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-7 §5 — unique constraints on `payment.idempotency_key` and `(provider, event_key)`, insert-first webhook handling, `sha256(rawBody)` fallback key, signature over the raw body, 24 h poll as reconciliation.
|
||||
`Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback.
|
||||
**Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice.
|
||||
|
||||
- [x] **FH-2.3 — Session and credential model** · M · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.1 — 32 random bytes stored as SHA-256 only, HttpOnly/Secure/SameSite, one cookie per contour, Argon2id params, mandatory TOTP with a single-use enrolment token, password change revokes all sessions in-transaction.
|
||||
Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥16 chars and revokes every live session in the same transaction. Role weights `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`, checked together with marketplace scope.
|
||||
**Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call.
|
||||
|
||||
- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.2 — origin allowlist ahead of routing on every admin/platform/manager mutation, same list for CORS.
|
||||
Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`.
|
||||
**Acceptance:** a cross-origin POST with a valid session cookie is refused.
|
||||
|
||||
- [x] **FH-2.5 — Tenant by verified Host only** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-9 §6 — normalization specified, `verifiedAt` required, cache with explicit invalidation, proxy header trust, no public endpoint accepts `marketplaceId`.
|
||||
Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser.
|
||||
**Acceptance:** an unknown Host returns 404 and leaks no other tenant's data.
|
||||
|
||||
- [x] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…`
|
||||
**Written 2026-08-21:** PHASE-9 §5.3 — HMAC preview token, 15 min, HttpOnly cookie, every non-GET 404s while preview is active, `noindex`.
|
||||
HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, `storefront_preview` cookie. Global hook returns `404 Preview mode is read-only` for any non-GET while that cookie is present. Preview is not indexable.
|
||||
**Acceptance:** a mutation attempted in preview mode is refused.
|
||||
|
||||
- [x] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…`
|
||||
**Written 2026-08-21:** PHASE-9 §5.1–5.2 — `version = max+1` unique per marketplace, materialized snapshot, pointer flipped in-transaction, rollback as a new revision, clone carry/no-carry list, inventory to zero, topological category walk.
|
||||
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection.
|
||||
**Acceptance:** rollback restores the chosen revision and leaves live inventory untouched.
|
||||
|
||||
- [x] **FH-2.8 — Append-only inventory journal** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.2 — `InventoryMovement` append-only with reason, reference, actor, and `resultingAvailable` written at the time.
|
||||
Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint.
|
||||
**Acceptance:** any current quantity is reconstructible from the journal alone.
|
||||
|
||||
- [x] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7`
|
||||
**Written 2026-08-21:** TRACK-S §4.2 — `v1.iv.tag.ciphertext` AES-256-GCM envelope, per-value IV, decrypt only in-service, HMAC fingerprints for display, backend-built allowlisted redirect URLs.
|
||||
AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted.
|
||||
**Acceptance:** no credential appears in any API response, JS bundle, or browser storage.
|
||||
|
||||
- [x] **FH-2.10 — Server-side storefront config validation** · M · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-10 §3a — server re-runs the editor rules, clamp-and-fallback ergonomics, structural violations 400, limits published as one schema, referential checks as publish blockers.
|
||||
The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list.
|
||||
**Acceptance:** a hand-crafted API call cannot store a config the editor would have refused.
|
||||
|
||||
- [x] **FH-2.11 — Digital goods** · M · `PHASE-3-…`
|
||||
**Written 2026-08-21:** PHASE-3 §6a — `FulfillmentMode`, `DigitalCode` states, `valueHash` unique per (marketplace, offer), codes revealed only when paid.
|
||||
`FulfillmentMode: MANUAL | CODE_POOL`. `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, variant)`. Codes revealed only when the order is `PAID`/`PROCESSING`/`FULFILLED`.
|
||||
**Acceptance:** an unpaid order never returns a code.
|
||||
|
||||
- [~] **FH-2.12 — Marketplace status machine** · **rejected 2026-08-21 — ours is better**
|
||||
Theirs is `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`. PHASE-9 §2 already carries `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, plus a lifecycle endpoint that must name the specific blocker preventing the next transition. Adopting theirs would be a downgrade. Recorded so it does not get raised again.
|
||||
|
||||
- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-2 §3.1 — `publicToken` ≥24 random bytes for every customer-facing route, tenant-scoped lookup, snapshot completeness, snapshots never updated in place.
|
||||
Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time.
|
||||
|
||||
- [x] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…`
|
||||
**Written 2026-08-21:** TRACK-S §8a — separate URL, shell, login and cookie; scope from membership rows not configuration; endpoints refuse rather than hide; PII masking and audited reveal.
|
||||
Separate URL, shell, cookie, and login; scoped to assigned marketplaces via **membership rows, not an environment variable** (their env-pinned slug is the one part not to copy). No visibility into catalog, design, domains, payment settings, or platform users. PII masked in lists, revealed in detail only with permission, and both export and reveal are logged.
|
||||
|
||||
- [x] **FH-2.15 — Bulk import: idempotency and rollback** · S · **done 2026-08-21**
|
||||
The validate-then-apply half already existed — PHASE-3 §6 has the preview of validation errors and a separate apply step, which is equivalent to their `dryRun`. What was missing and is now written: the import is **idempotent by SKU/external key** so re-running a file updates rather than duplicates, a row-level error never publishes a partial result, and an applied import is rollback-able only while none of its products have appeared on a paid order.
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 — Proof (Lane A)
|
||||
|
||||
- [ ] **FH-3.1 — E2E: concurrent purchase of the last unit** · M · `e2e/`
|
||||
Their §22 scenario 3. Two sessions race for the final unit; exactly one payable order results, the other gets a clean out-of-stock state.
|
||||
|
||||
- [ ] **FH-3.2 — E2E: replayed webhook** · M · `e2e/`
|
||||
Their §22 scenario 10. The same provider event delivered twice does not complete the order twice or move stock twice.
|
||||
|
||||
- [x] **FH-3.3 — Bundle budget as a blocking CI check** · S · **done 2026-08-21**
|
||||
`maximumError` on the initial bundle lowered `1.8MB → 1.6MB` in `angular.json`, and again to `1.1MB` once FH-3.4 landed. Measured today: **1.55 MB raw / 324.58 kB transfer** — worse than the 1.15 MB they measured on 11 Aug, so this had been growing unwatched. The threshold is a **ratchet**, not the target: set just above today's size so the bundle cannot grow, with the 700 kB warning left in place as the goal. Lower it every time the number comes down. CI now runs the production build (`npm run build` already defaults to production).
|
||||
|
||||
- [~] **FH-3.4 — Get the initial bundle down** · L · **1.55 MB → 1.04 MB on 2026-08-21; not yet at target**
|
||||
**Premise corrected after measuring.** Admin, editor, catalog, cart and the `en`/`hy` locales are *already* lazy chunks — nothing admin-shaped ships to an anonymous visitor. The whole initial bundle is `main` alone, so this was never a "split the deployables" job.
|
||||
|
||||
Built with `--stats-json` and read the esbuild metafile rather than guessing. Composition of the 1.5 MB `main`:
|
||||
|
||||
| bytes | what |
|
||||
|---:|---|
|
||||
| 495,831 | `@angular/compiler` |
|
||||
| 290,589 | `src/app/i18n/ru.ts` |
|
||||
| 162,915 | `@angular/core` |
|
||||
| 82,209 | `@angular/router` |
|
||||
| 53,354 | `@angular/common` |
|
||||
|
||||
**`@angular/compiler` — 33% of the bundle — was the JIT compiler, in an AOT production build.** `src/main.ts` imported it explicitly, with a comment explaining that `@marketplaces/auth` shipped plain `tsc` output carrying no Ivy metadata, so Angular JIT-compiled its classes at runtime and bootstrap threw without it.
|
||||
|
||||
That comment was **stale**. The package is `0.2.0`, built with ng-packagr, `module: dist/fesm2022/marketplaces-auth.mjs` — proper Angular Package Format, partial-compiled (`ɵɵngDeclareInjectable`), linked at consumer build time. No JIT needed.
|
||||
|
||||
Removed the import. Verified against the **production** bundle served statically, not just a successful build — the failure mode it guarded was a runtime throw, so a green build proves nothing. Angular 22.0.8 bootstrapped, the router resolved `/ru`, and the app rendered its own "server unavailable" screen, which means DI, HttpClient and the whole interceptor chain ran. That chain injects `AuthService` from `@marketplaces/auth` — the exact class named in the old comment. Zero JIT/compiler errors; the only console output was the expected 404s from having no backend.
|
||||
|
||||
**Result: 1.55 MB → 1.04 MB raw, 323.58 kB → 215.45 kB transfer.** Ratchet lowered `1.6MB → 1.1MB`, which is now also the guard against the import being re-added.
|
||||
|
||||
**Remaining path to the 700 kB target.** The next lever is `i18n/ru.ts` at 290 kB — the default locale, eager, while `en`/`hy` are lazy. `TranslateService` already has the loader plumbing and `languageGuard` already awaits a preload before route activation, so making `ru` lazy is mechanically small. It is deliberately **not** done here: it adds a round-trip before first paint for the majority language, which is a product tradeoff rather than a cleanup. Needs a decision, then it is roughly a 750 kB bundle.
|
||||
Also outstanding: `qrcode` (23.7 kB), pulled in by `@marketplaces/auth`, is not ESM and causes an optimizer bailout — a fix for the package repo.
|
||||
|
||||
- [x] **FH-3.5 — Bundle secret scan in CI** · S · **done 2026-08-21**
|
||||
`scripts/ci/scan-bundle.sh`, wired as `npm run scan:bundle` and a CI step after Build. Seven patterns: both provider auth headers, the partner ID shape, `client_secret`, private key blocks, AWS keys, Telegram bot tokens. Verified in both directions — clean against the real `dist/`, and fails with exit 1 against a planted credential.
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 — Identity: VK ID + Yandex ID (Lane C, `@marketplaces/auth`)
|
||||
|
||||
Nothing to copy from the archive — it has zero VK/Yandex/OAuth code. We take the session-issuing shape of their Telegram flow and terminate both providers into it.
|
||||
|
||||
The **client half and the contract are done** (2026-08-21). What remains is backend implementation, and registering the OAuth applications — which is what FH-0.1 gates.
|
||||
|
||||
- [x] **FH-4.1 — Provider-agnostic social identity surface** · M · **done 2026-08-21**
|
||||
Landed as `social-identity-gateway.interface.ts` / `-api.gateway.ts` / `-local.gateway.ts` / `-gateway.token.ts` under `src/app/core/identity/services/`, with the four `vk-id-*` files deleted and `vk-id-login` replaced by `social-login-button` taking a `provider` input. `'yandex_id'` added to `ExternalIdentityProvider`. Covered by `social-identity-gateway.spec.ts` (5 tests), which asserts the request carries no `code_verifier` or `client_secret` — so re-adding a browser-held verifier fails the build rather than passing review.
|
||||
Collapse `VkIdGateway` into `SocialIdentityGateway`:
|
||||
```ts
|
||||
export type SocialProvider = 'vk' | 'yandex';
|
||||
export interface SocialIdentityGateway {
|
||||
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||
listIdentities(): Observable<ExternalIdentity[]>;
|
||||
unlink(provider: SocialProvider): Observable<void>;
|
||||
}
|
||||
```
|
||||
Touches: `src/app/core/identity/services/vk-id-gateway.interface.ts`, `vk-id-api.gateway.ts`, `vk-id-local.gateway.ts`, `vk-id-gateway.token.ts`, `src/app/components/vk-id-login/` → `social-login-button`. Add `'yandex_id'` to `ExternalIdentityProvider` in `core/identity/models/customer-identity.model.ts`.
|
||||
|
||||
- [x] **FH-4.2 — Move PKCE ownership to the backend** · S · **done 2026-08-21**
|
||||
`completeCallback()` is gone from the frontend entirely. PHASE-8 §2.1–2.2 rewritten: `/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` single-use for 10 minutes, `/callback` is a backend GET that exchanges, links, issues the session cookie and redirects. `returnTo` validated against the tenant's own origin.
|
||||
Today `completeCallback(code, codeVerifier)` forces the browser to generate and hold the verifier. We are a confidential client. Backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, handles the callback, and redirects. `completeCallback()` leaves the frontend entirely.
|
||||
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `backend/BACKEND-INTEGRATION.md` §2.
|
||||
|
||||
- [~] **FH-4.3 — `ExternalIdentity` model** · S · Lane B · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §1 and §2.3: `UNIQUE (provider, providerUserId)`, conflict routes to controlled resolution rather than rebinding, optional email/phone/displayName, per-tenant OAuth app config under the Track S §4.2 envelope.
|
||||
```prisma
|
||||
@@unique([provider, providerUserId]) // one provider account -> one customer
|
||||
```
|
||||
Conflict is **not** an upsert: a `providerUserId` already bound to a different `Customer` routes to controlled resolution. The unique index makes the database refuse a silent rebind. Per-tenant OAuth app config stored encrypted (same envelope as FH-2.9): `{ clientId, clientSecret, scopes[], redirectUri }`.
|
||||
|
||||
- [~] **FH-4.4 — VK ID** · M · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §2.5 carries the full endpoint set and the `device_id` trap.
|
||||
OAuth 2.1, PKCE mandatory (S256). Authorize `https://id.vk.com/authorize`; token `POST https://id.vk.com/oauth2/auth`; profile `POST https://id.vk.com/oauth2/user_info`; logout `https://id.vk.com/oauth2/logout` on unlink.
|
||||
**Trap to write into the contract:** the callback returns `device_id` alongside `code`, and the token exchange fails without it. This is the most common VK ID integration bug.
|
||||
VK often does not return an email — email must stay optional on `Customer`.
|
||||
|
||||
- [~] **FH-4.5 — Yandex ID** · S · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §2.5. On the client it is a `provider` input, not new code.
|
||||
OAuth 2.0 with PKCE. Authorize `https://oauth.yandex.ru/authorize`; token `POST https://oauth.yandex.ru/token` with HTTP Basic `client_id:client_secret`; profile `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <token>` → `id`, `login`, `default_email`, `default_phone`, `psuid`.
|
||||
A second strategy object against the same surface — roughly a day once VK works.
|
||||
*Confirm exact parameter and scope names against live provider docs; both providers revised their flows recently.*
|
||||
|
||||
- [~] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · **client + contract done 2026-08-21; backend write path pending**
|
||||
Client: the gateway now separates the two provider sets — `SocialProvider` (`vk`/`yandex`, has an OAuth authorize) vs `ExternalIdentityProvider` (adds `telegram`/`max`, listable and unlinkable). `unlink()` takes the wider type, so the linking UI detaches Telegram through the same path as VK. The dev local gateway seeds a Telegram identity so the surface is exercisable now.
|
||||
Contract: PHASE-8 §2.6 — Telegram login writes an `ExternalIdentity` row under the same uniqueness/conflict rule as VK, appears in `/me/identities`, is removable subject to the last-identity `409`, and — the audit finding — keeps customer (`marketplace_session`) and admin (`bo_session`) sessions as separate cookies so a Telegram customer never satisfies an admin guard. Identity row vs messaging `BotConversationBinding` kept distinct.
|
||||
Backend still owns: the actual write-on-login and the session split enforcement. Telegram login itself lives in `@marketplaces/auth`.
|
||||
|
||||
- [x] **FH-4.7 — Account linking UI** · M · **done 2026-08-21**
|
||||
`AccountIdentitiesComponent` (`src/app/features/website/account/identities/`): lists linked identities from `GET /me/identities`, offers attach buttons only for OAuth providers not yet linked (reusing `SocialLoginButtonComponent`), detaches through `unlink()`, disables the detach control on the last remaining identity with an explanatory title, and has a slot for the §2.3 conflict message. Loading / error / ready states, error surfaced rather than shown as an empty account. 6 unit tests. Not yet wired into a route — the storefront has no customer account area and no live OAuth app (FH-0.1) — but fully built and tested behind that.
|
||||
|
||||
- [x] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · **done 2026-08-21**
|
||||
PHASE-8 §3 now states it explicitly: OTP is a way back in when a linked messenger is unreachable and a second factor a customer may add, never the front-and-centre first login option, and one more `ExternalIdentity`/`ContactMethod` on the same customer rather than a parallel account. The [email/phone spec](superpowers/specs/2026-08-15-email-phone-login-design.md) stays valid; only its priority relative to VK ID moves.
|
||||
|
||||
---
|
||||
|
||||
## Continuous — Ops (Lane D)
|
||||
|
||||
- [ ] **FH-D.1 — Proven restore drill** · M
|
||||
We have deploy automation and no proven restore. Add a restore-check script and schedule it. Their `restore-check.sh` + WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) is the model.
|
||||
**Done when:** a restore into a clean environment has been executed and its result recorded.
|
||||
|
||||
- [ ] **FH-D.2 — Database unreachable from the internet, structurally** · S · Lane B/D
|
||||
Data network `internal: true`; API bound to loopback only; `no-new-privileges` on every service. Makes it a property of the topology rather than a firewall promise.
|
||||
|
||||
- [x] **FH-D.3 — Host hardening we lack** · M · **done 2026-08-21**
|
||||
Three drop-in files in `scripts/deploy/server-setup.sh`, documented in [DEPLOYMENT.md](DEPLOYMENT.md) §3.2: sshd hardening (password and keyboard-interactive auth off, root key-only, `MaxAuthTries 3`, 30 s grace, no forwarding), fail2ban (`sshd`, `nginx-http-auth`, `nginx-bad-request`; 5 failures in 10 min, 1 h ban), and sysctl (no redirects or source routing, rp_filter, SYN cookies, forwarding off, restricted kernel pointers and dmesg). The script runs `sshd -t` before reloading and removes its own drop-in if the test fails — a bad sshd config taking effect remotely is how people lock themselves out permanently.
|
||||
Scoped sudoers was already in place. *Kept ours where ours is better:* `add-domain.sh` pre-checks the DNS A record and runs `nginx -t` before and after; ufw was already configured. Their hardcoded server IP deliberately not copied.
|
||||
|
||||
---
|
||||
|
||||
## Continuous — Process (Lane E)
|
||||
|
||||
- [x] **FH-E.1 — Adopt the nine invariants as an acceptance gate** · S · **done 2026-08-21**
|
||||
Now `backend/BACKEND-INTEGRATION.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished.
|
||||
|
||||
- [x] **FH-E.2 — PR policy** · S · **done 2026-08-21**
|
||||
`backend/BACKEND-INTEGRATION.md` §0a, with the expand/contract migration rule alongside it.
|
||||
|
||||
- [x] **FH-E.3 — Release discipline** · S · **done 2026-08-21**
|
||||
`backend/BACKEND-INTEGRATION.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available.
|
||||
|
||||
- [x] **FH-E.4 — ADR for the harvest** · S · **done 2026-08-21**
|
||||
[ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). Records what we take, what we reject, what we keep because ours is better, and the one organizational question it deliberately does not settle.
|
||||
|
||||
- [x] **FH-E.6 — Keep mock gateways out of production builds** · M · **done 2026-08-21**
|
||||
21 token factories now `inject(XApiGateway)` unconditionally; mock overrides moved to `src/app/mock-gateway.providers.ts`, swapped for a production copy that imports nothing via `fileReplacements`. Zero `*LocalGateway` classes and zero fixtures in the production bundle, down from 21 classes and 75 kB of source. `scan-bundle.sh` gained two patterns so it cannot return, verified in both directions. Dev behaviour unchanged — flip `useMockData` in `environment.ts` as before.
|
||||
Worth noting for whoever picks up FH-E.5: `useMockData` is `false` in **both** environment files, so none of these mocks were ever the selected implementation. They were pure weight.
|
||||
**Not fixed here:** `MediaRepository` is still bound to `MockMediaRepository` unconditionally in `app.config.ts`. That one cannot simply be deleted — no real implementation exists — so it is a missing API gateway, not dead weight.
|
||||
Measured 2026-08-21: mock seed data reaches the production bundle. `ptr_local`, a fixture literal from `partner-hierarchy-local.gateway.ts`, is present in a built lazy chunk. Cause: 21 DI tokens use `factory: () => (environment.useMockData ? inject(XLocalGateway) : inject(XApiGateway))`, and referencing both branches keeps both classes reachable, so the optimizer cannot drop the mock. 75 kB of local-gateway source, plus its fixtures, ships to users.
|
||||
This is the concrete form of their strongest objection — "mock repositories as production implementation" — and it is mechanical to fix. The pattern to copy is already in this repo: `mock-data.interceptor.production.ts` swapped in via `fileReplacements`.
|
||||
**Done when:** `scan-bundle.sh` can gate on mock fixture markers and pass.
|
||||
|
||||
- [x] **FH-E.5 — Reduce `localStorage` to cache, never truth** · M · **audited + gap marked 2026-08-21**
|
||||
Audited all 21 `localStorage` users. The premise — "localStorage is your source of truth" — turned out **already false** across the app:
|
||||
- Every admin facade (products, categories, orders, moderation, dashboard) uses `localStorage` for **view preferences only** — `viewMode`, `density`, `visibleColumns`, `expandedIds`, `sort`. Entity CRUD goes through the API gateways. That is cache, not truth.
|
||||
- `currency-rates.service.ts` already removed its localStorage-typed rates (its own comment records it).
|
||||
- `language`, `location` region, `search-history`, `api-headers` anonymous session id, `admin-preferences` — all legitimate preference/cache.
|
||||
- The editor already surfaces an **"unsaved local draft restored"** banner (`draftRestored` → save bar), which is the recovery-cache indicator this item asked for.
|
||||
|
||||
**One real gap, and it is backend-blocked:** the project editor's `publish()` applies config to the in-memory runtime and saves the draft to localStorage, then declares itself published — no server round-trip, because the PHASE-9 §5 revision API does not exist yet. Marked precisely in `publish()` with the required behaviour (await the server, only then mark published) and cross-referenced to the contract. Cannot be finished on the frontend alone; the contract for the fix is already written.
|
||||
|
||||
Net: nothing to rip out — the codebase was already at the target state everywhere the backend exists to support it.
|
||||
---
|
||||
|
||||
## Scoreboard
|
||||
|
||||
| Wave | Done | Contract written, awaiting backend | Open | Blocked by |
|
||||
|---|---:|---:|---:|---|
|
||||
| 0 — Decide | 0 | — | 2 | needs a person, not a session |
|
||||
| 1 — Live defects | 2 | — | 2 | FH-1.2 / FH-1.4 sit in files another session owns |
|
||||
| 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) |
|
||||
| 3 — Proof | 3 | 1 | 1 | see note below |
|
||||
| 4 — Identity | 4 | 3 | 1 | OAuth apps, which FH-0.1 gates |
|
||||
| Ops | 1 | — | 2 | — |
|
||||
| Process | 6 | — | 0 | — |
|
||||
| **Total** | **30** | **4** | **8** | 1 rejected |
|
||||
|
||||
**Landed 2026-08-21**
|
||||
|
||||
- **Wave 1** — FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration).
|
||||
- **Wave 2** — all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits: our lifecycle state machine is richer than theirs.
|
||||
- **Wave 3** — FH-3.3 (bundle budget ratcheted to a blocking error at 1.6 MB, measured 1.55 MB), FH-3.5 (`scripts/ci/scan-bundle.sh`, in CI, verified in both directions).
|
||||
- **Wave 4** — FH-4.1 and FH-4.2 complete on the client and in the contract; FH-4.3–4.5 specified and waiting on backend plus registered OAuth applications.
|
||||
- **Process** — FH-E.1–E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
|
||||
|
||||
Test count over the session: 247 -> 256 (+4 geo, +5 social identity). Initial bundle 1.55 MB -> 1.04 MB. Build green, boundary and cycle checks green.
|
||||
|
||||
**On FH-3.1 / FH-3.2 — reclassified, not skipped**
|
||||
|
||||
Both are backend races: two transactions competing for the last unit, and the same provider event arriving twice. Playwright against mocked routes cannot prove either — a test that mocks both sides of a race proves only that the mock behaved. `checkout-idempotent-click.spec.ts` already says this in its own header and covers the genuinely frontend-testable half.
|
||||
|
||||
So the acceptance criteria now live where they bind, as normative text in PHASE-3 §3.1 and PHASE-7 §5, and the e2e work they imply is **backend integration testing**, not frontend e2e. What *is* worth doing on our side first: the existing checkout e2e specs have a known-failing session setup (documented in-file, dated 2026-08-21) — a green suite is the prerequisite for anything built on top of it.
|
||||
|
||||
**Next**
|
||||
|
||||
1. **FH-0.1** — the central identity host, and the one-customer-or-two question. One-way door, gates the remaining Wave 4 work, needs a decision from a person.
|
||||
2. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick up once that work settles.
|
||||
3. **FH-E.6** — mock fixtures reach production chunks. The fix is mechanical but touches 21 DI token files plus `app.config.ts`, which another session currently owns — deliberately deferred rather than merged into a busy tree. Plan: move mock selection out of the token factories into one dev-only provider array swapped by `fileReplacements`, the same mechanism `mock-data.interceptor.production.ts` already uses.
|
||||
4. **Fix the e2e session setup**, then revisit what proof is worth adding.
|
||||
@@ -1,54 +0,0 @@
|
||||
# FRONTEND
|
||||
|
||||
Angular 18+, standalone components throughout (no NgModules). See `docs/PROJECT-STRUCTURE.md` for the full `src/app/**` folder tour and `docs/ARCHITECTURE.md` for the layered container/facade/service pattern.
|
||||
|
||||
## App structure at a glance
|
||||
|
||||
```
|
||||
src/app/
|
||||
core/ domain services, DTOs, mappers, repositories (per domain: categories, products, search, admin-auth)
|
||||
facades/ cross-feature facades (platform/category.facade.ts, platform/search.facade.ts, ...)
|
||||
features/ feature modules (project-editor, admin/*, backoffice/*, website/catalog, website/product, diagnostics, content-management, search)
|
||||
shared/ models/config (BootstrapConfig + ~20 sub-configs), shared UI, utils — feature-agnostic
|
||||
widgets/ widget contracts, registry/manifest, resolvers, ui components
|
||||
dynamic-renderer/ section-engine, page-renderer, section-renderer, widget-host
|
||||
layouts/ page-chrome containers (dynamic-page-layout, header/footer shells)
|
||||
i18n/ translations.ts (interface), en.ts, ru.ts, hy.ts, translate.pipe.ts, TranslateService
|
||||
pages/ top-level routed pages (home, cart, category, ...)
|
||||
components/ reusable standalone components used across features (product-card, telegram-login, ...)
|
||||
guards/ route guards (admin-auth guard, etc.)
|
||||
```
|
||||
|
||||
## Routing (`app.routes.ts`)
|
||||
|
||||
- Locale-prefixed routes: `/:lang/...` (lang from `LanguageService.currentLanguage()`), plus root redirects.
|
||||
- Storefront: `/`, `/catalog`, `/catalog/:id`, `/product/:id` (legacy `/item/:id` and `/category/:id[/items]` redirect for compatibility).
|
||||
- Static/CMS pages resolve dynamically: `/:lang/:staticPath` (legacy `/:lang/page/:key` kept for compatibility) — no hardcoded page list, resolved from `bootstrap.staticPages`.
|
||||
- Project Editor: `/edit/:section` or `/{lang}/edit/:section`.
|
||||
- Admin/backoffice: `/:lang/backoffice/**`, guarded by `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`) — dashboard, products, categories, orders, transactions, users, moderation, media, monitoring, analytics. See `docs/BACKEND.md` for the data-source contract for each.
|
||||
- Dev-only diagnostics: `/__diagnostics` (excluded from production).
|
||||
|
||||
## i18n system
|
||||
|
||||
- `src/app/i18n/translations.ts` defines the `Translations` interface — the single schema every locale file must satisfy (TypeScript enforces this at compile time: a missing key in any locale is a build error).
|
||||
- `en.ts`, `ru.ts`, `hy.ts` implement that interface, keyed identically and nested by feature area (`header`, `footer`, `home`, `builder`, `dashboard`, ...).
|
||||
- `TranslateService` resolves the active locale and exposes translated strings; `TranslatePipe` (`| translate`) is the template-facing API — **never hardcode user-facing strings in templates**, always add a key to all three locale files.
|
||||
- 3 locales: `en`, `ru`, `hy` (Armenian). `LanguageService` tracks the active locale and drives the `/:lang/` route prefix.
|
||||
- Adding a new UI string: add the key to the `Translations` interface first, then to `en.ts`/`ru.ts`/`hy.ts` in the same position (see `docs/EDITOR.md` for the pattern used by the field-description work).
|
||||
|
||||
## Theming
|
||||
|
||||
- 3 tenant theme stylesheets: `src/styles/themes/*.theme.scss`.
|
||||
- Convention: each theme file defines CSS custom properties (`--color-primary`, `--text-primary`, `--border-color`, etc.) that mirror `ThemeConfig.palette`/`typography`/`shadows`/`borderRadiusScale`; components and widgets consume only these custom properties, never hardcoded hex values (ADR-008).
|
||||
- `theme.mode` (`light | dark | system`) and the palette are runtime-configurable per tenant via bootstrap and editable via the Project Editor's Theme section (`docs/EDITOR.md`).
|
||||
|
||||
## State management
|
||||
|
||||
- **Signals-based facades, no NgRx.** Every feature/domain exposes a facade (`ProjectEditorFacade`, `CategoryFacade`, `ProductFacade`, `SearchFacade`, `AdminDashboardFacade`, ...) built on Angular signals (`signal`, `computed`, `effect`), following ADR-007.
|
||||
- Components inject exactly one facade and read/write through it; no direct service or HTTP access from components (ADR-006).
|
||||
- Local component state (e.g. draft form values) stays in the component; cross-cutting/shared state lives in the facade.
|
||||
- Persistence for local-only features (Project Editor drafts, admin dashboard activity history) uses scoped `localStorage` keys behind a dedicated service (`ProjectEditorDraftStorageService`, `AdminDashboardHistoryService`) — never raw `localStorage` calls from components/facades.
|
||||
|
||||
## Dynamic widget/section rendering from bootstrap JSON
|
||||
|
||||
Full detail in `docs/ARCHITECTURE.md` and `docs/BACKEND.md#1-bootstrap`. Summary: `page config (bootstrap.pages) -> Section Engine (order/layout/visibility) -> Page Renderer -> Widget Host (resolves component via Widget Manifest + data via Data Source Resolver) -> widget component (props + resolved data only)`. Nothing in this pipeline calls an API directly except the Data Source Resolver, which delegates to `CategoryFacade`/`ProductFacade`.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Future Features
|
||||
|
||||
Nice-to-have, non-blocking work — no client decision needed, just not worth doing now. Verified against current repo state 2026-07-26.
|
||||
|
||||
## Cart payment modal → `app-dialog` migration
|
||||
|
||||
`.bank-payment-modal` on the cart page is a custom overlay component with its own focus-trap (added during the WCAG audit) rather than the shared `app-dialog` primitive. Functionally and accessibly complete as-is — migrating it to the shared primitive is a composition cleanup, deliberately deferred across every polish pass so far because it touches multi-step payment state.
|
||||
|
||||
## Angular 22 upgrade
|
||||
|
||||
Researched, not executed. Estimated ~2–3.5 days, needs the `barry-cache` dependency fix and a Node version bump first. Explicitly out of scope for the Backend Finalization Sprint. Plan: `docs/ANGULAR22_PLAN.md`.
|
||||
|
||||
## Bundle splitting
|
||||
|
||||
Two lazy chunks are large: `project-editor` (320 kB), `catalog-container` (126 kB). No mechanical split found yet — needs a dedicated profiling task.
|
||||
|
||||
## Homepage hero-to-categories spacing investigation
|
||||
|
||||
A dead-space gap between the hero and categories section on the storefront home page traces to bootstrap mock config (widget/section padding values in the dev fixture), not a confirmed code defect. Needs reproduction with real tenant data before it's worth investigating further — not a bug until it's confirmed to happen outside the mock fixture.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Known Issues
|
||||
|
||||
Real, reproducible, currently-open frontend bugs only. Everything that needed a product/business decision moved to `docs/PRODUCT_BACKLOG.md`; everything nice-to-have moved to `docs/FUTURE_FEATURES.md`; everything backend-shaped moved to `docs/BACKEND.md`. Re-verified against source 2026-07-26.
|
||||
|
||||
## Open
|
||||
|
||||
1. **Ed25519 admin-auth error codes `session-expired` and `invalid-signature` are unreachable — dead UI.**
|
||||
`AuthError.code` is documented as routing to a dedicated recovery screen per code
|
||||
(`core/auth/models/auth-error.model.ts:1-4`), but `toAuthErrorShape()` in
|
||||
`core/auth/services/auth.service.ts:110-118` derives the code for any real
|
||||
`HttpErrorResponse` *exclusively* from `authErrorCodeFromStatus(error.status)`
|
||||
(line 112) — it never reads the caller-supplied `fallbackCode` parameter for
|
||||
real HTTP errors, and never reads any body-level error code from the response.
|
||||
`authErrorCodeFromStatus()` (`auth-error.model.ts:21-32`) only ever returns
|
||||
`'unauthorized'`, `'forbidden'`, or `'backend-unavailable'` — there is no status
|
||||
or body condition anywhere in the codebase that produces `'session-expired'` or
|
||||
`'invalid-signature'`. Both screens exist and are wired, but are permanently
|
||||
unreachable from any real backend response today.
|
||||
- **Fix requires both sides**: a backend that returns a distinguishable
|
||||
`error.code` in the response body (see `docs/BACKEND.md` §6 Error Model), and a small
|
||||
frontend change to `toAuthErrorShape()` to prefer that body code over the
|
||||
blanket status-based fallback.
|
||||
- Found: 2026-07-26, Backend Finalization Sprint documentation pass (traced while
|
||||
writing `docs/BACKEND.md` §4 Authentication / §6 Error Model).
|
||||
|
||||
## Fixed (this cycle)
|
||||
|
||||
Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
|
||||
|
||||
- App-wide query-param routing broken (P0) — `language.guard.ts` legacy redirect percent-encoded query strings into the path.
|
||||
- Backoffice Categories CRUD broken end-to-end (P0) — wrong provider-mode fallback always picked the real HTTP gateway with no backend present.
|
||||
- Cart/builder native `confirm()`/`alert()` (16 call sites) replaced with shared `app-confirm-dialog` / toast service.
|
||||
- `getMainImage()` no-photo fallback and footer payment-icon assets referenced files that didn't exist — both fixed, `onerror` fallback added everywhere.
|
||||
- Backoffice Monitoring showed raw HTTP/queue/webhook strings by default — now friendly wording with technical detail collapsed behind a `<details>`.
|
||||
- Category/subcategory empty states used apology wording ("Oops!") for a normal zero-results state.
|
||||
- `pages/category`, `pages/search`, `pages/item-detail`, `pages/info/**`, `pages/legal/**` (40+ files) were unrouted dead code — deleted.
|
||||
- `dynamic-renderer/` was believed unwired — verified it's the live homepage rendering pipeline, no action needed.
|
||||
- `admin/products/:id/edit` missing `canDeactivate` guard — added, mirrors categories.
|
||||
- `primeng`/`primeicons` unused dependency — removed.
|
||||
- Builder static-page body editor hidden inside a mislabeled collapsed section — un-hidden, relabeled.
|
||||
- Several project-editor/admin-categories correctness bugs (footer icon id collisions, features toggle only driving one flag, languages silent duplicate no-op, static-pages slug collision, branding `socialImageUrl` never read, media-picker facade filter leakage between dialogs, categories draft-recovery/drag-reorder bugs, hardcoded locale-tab order) — see git history for the full per-bug list.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Next Phase — Roadmap
|
||||
|
||||
The one roadmap. Everything after this point assumes the previous phase is done — don't start Phase 2 work before Phase 1 lands.
|
||||
|
||||
## Phase 1 — Backend integration
|
||||
|
||||
Implement the backend per `BACKEND.md`, then swap every frontend mock gateway for a real one behind its DI token, in the dependency order `BACKEND.md` §8 specifies (auth/tenant/bootstrap first, then read-heavy catalog, then write-heavy customer domains, then admin, then builder/CMS). Wire the currently-dormant Ed25519 admin-auth interceptor/guard once the backend can issue/verify challenges. Enforce the admin role model in route guards once real roles exist server-side.
|
||||
|
||||
## Phase 2 — Production testing
|
||||
|
||||
Add the automated test suite that doesn't exist yet: facade-level integration tests against real endpoints (not mocks), and E2E coverage for the critical flows — storefront checkout, admin product/category CRUD, builder draft → publish → live storefront reflects the change, admin auth once Ed25519 is live.
|
||||
|
||||
## Phase 3 — Performance
|
||||
|
||||
Re-profile under real backend latency (mock responses are instant today, real ones won't be) — loading states, skeleton timing. Revisit the two known large lazy chunks (`project-editor`, `catalog-container`) with real data before committing to a bundle-splitting approach.
|
||||
|
||||
## Phase 4 — Monitoring
|
||||
|
||||
Wire real error tracking/APM and a real event source for the admin Monitoring page (currently mock activity data). Implement the maintenance-mode frontend UI gaps `BACKEND.md` §10 flags as not existing yet (full-page takeover, per-module banners, scheduled-maintenance countdown), once the backend maintenance contract is live.
|
||||
|
||||
## Phase 5 — Version 2 ideas
|
||||
|
||||
Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch.
|
||||
58
docs/PACKAGE-EXTRACTION.md
Normal file
58
docs/PACKAGE-EXTRACTION.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# @marketplaces/auth & @marketplaces/payment — build, version, release, infrastructure
|
||||
|
||||
See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) for why. This doc is the how. For *consuming* the packages (install, DI providers, exported API), see [PACKAGES-USAGE.md](PACKAGES-USAGE.md).
|
||||
|
||||
## Current state
|
||||
|
||||
Working end to end with no credentials. `marketplaces` has no local copy of either package and no `.npmrc` — it installs `@marketplaces/auth` directly over git. A fresh clone plus `npm install` builds and tests green on any machine or CI runner.
|
||||
|
||||
## 1. Source repo
|
||||
|
||||
[sources.vitanova.network/sdarbinyan/vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git) — npm workspaces monorepo, `packages/auth` + `packages/payment`, source on `main`.
|
||||
|
||||
## 2. How releases work
|
||||
|
||||
npm cannot install a subdirectory of a git repo, so each package is published to its own **release branch** where the repo root *is* the package: `release/auth`, `release/payment`. Each contains only `package.json`, the built `dist/`, and a generated README.
|
||||
|
||||
```
|
||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
|
||||
```
|
||||
|
||||
This was chosen over a registry because it needs **nothing**: no npm registry, no token, no tunnel, no CI secret. Anonymous git read is the only requirement, which is what makes CI and fresh clones work unattended.
|
||||
|
||||
`release/*` branches are generated and force-pushed. Never commit to them by hand.
|
||||
|
||||
## 3. Versioning
|
||||
|
||||
[Changesets](https://github.com/changesets/changesets). A PR that changes a package adds a changeset file (`npx changeset` at the repo root — pick package, bump type, one-line description). `ci.yml` rejects PRs without one.
|
||||
|
||||
## 4. CI/CD (vitanovaPackages)
|
||||
|
||||
- **`ci.yml`** — on PRs and non-main pushes: install, build, test, require a changeset.
|
||||
- **`release.yml`** — on push to `main`, two jobs:
|
||||
- `release-branches` (matrix over `auth`/`payment`): builds each package and force-pushes its output to `release/<pkg>`. Skips cleanly when nothing changed.
|
||||
- `version-pr`: opens/updates a "Version Packages" PR when unreleased changesets exist. Merging it bumps versions on `main`, which re-triggers the release.
|
||||
|
||||
Only the checkout token is needed — no secrets to configure.
|
||||
|
||||
Workflows use GitHub Actions syntax; Gitea/Forgejo Actions are compatible. Other CI needs translating (steps are: install, build, test, force-push a branch).
|
||||
|
||||
## 5. The Verdaccio registry (superseded, still running)
|
||||
|
||||
A private Verdaccio instance runs on the dev server: Docker container `verdaccio`, port 4873, config and storage at `/srv/marketplaces/verdaccio/`, registry user `marketplaces-ci`. It holds `@marketplaces/auth@0.1.0` and `@marketplaces/payment@0.1.0`.
|
||||
|
||||
**Nothing uses it.** It was the original plan, but it listens on `127.0.0.1:4873` and the server firewall allows only 80/443/SSH — so no CI runner and no developer could reach it without an SSH tunnel, which defeats the point. The git-release-branch approach (§2) replaced it.
|
||||
|
||||
Keep it or remove it; no code or workflow depends on it. To reach it manually:
|
||||
|
||||
```bash
|
||||
ssh -L 4873:127.0.0.1:4873 seto@213.21.246.138
|
||||
```
|
||||
|
||||
Making it the primary path again would need a reverse proxy through nginx plus TLS (no certificate exists on that box), or an open port carrying credentials over plain HTTP — neither is done, and neither is necessary now.
|
||||
|
||||
## 6. Migration status
|
||||
|
||||
**Auth: done.** `@marketplaces/auth` holds the real implementation — `telegram/` (live QR/session auth, customer + admin) and `ed25519/` (challenge/response admin auth, backend not shipped). Environment coupling was replaced with `AUTH_API_URL`/`TELEGRAM_BOT_USERNAME` injection tokens; `environment.production` became Angular's `isDevMode()`. `AdminPermissionsService` and `requireAdminPermission` stayed in `marketplaces` (`core/admin-auth/`) — they read this app's mock Users domain, not a portable auth concern. All ~30 call sites import from the package; the old in-app auth files are deleted. Build, boundary checks, and 103/103 tests pass.
|
||||
|
||||
**Payment: not started.** `core/finance`/`core/pricing` still live in `marketplaces`. `@marketplaces/payment` is published as an empty scaffold and is not a dependency of anything.
|
||||
126
docs/PACKAGES-USAGE.md
Normal file
126
docs/PACKAGES-USAGE.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Using `@marketplaces/auth` and `@marketplaces/payment`
|
||||
|
||||
How to install and consume the shared packages in `marketplaces` or any other project. For *why* they exist see [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md); for how they are built and released see [PACKAGE-EXTRACTION.md](PACKAGE-EXTRACTION.md).
|
||||
|
||||
## 1. Install
|
||||
|
||||
Nothing to set up. The packages are installed straight over git from release branches in [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git), where the repo root *is* the package:
|
||||
|
||||
```json
|
||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
|
||||
```
|
||||
|
||||
That is already in `marketplaces`' `package.json`, so a fresh clone plus `npm install` just works — **no npm registry, no auth token, no SSH tunnel, no CI secret.** Anonymous git read is the only requirement.
|
||||
|
||||
To add it to another project:
|
||||
|
||||
```bash
|
||||
npm install "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
|
||||
```
|
||||
|
||||
**On pinning:** a branch ref tracks the tip, so `npm install` can pick up a new build. That is deliberate while the package churns. For reproducible installs, replace `#release/auth` with a commit SHA. See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) on blast radius.
|
||||
|
||||
## 2. Required providers
|
||||
|
||||
`@marketplaces/auth` has no knowledge of any specific app's environment config. It reads two injection tokens, both provided by the consuming app in `app.config.ts`:
|
||||
|
||||
```ts
|
||||
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
|
||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||
// ...
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
| Token | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `AUTH_API_URL` | yes | Base URL of the auth backend, e.g. `https://api.example.com`. Both auth mechanisms build their endpoints from this. |
|
||||
| `TELEGRAM_BOT_USERNAME` | no | Bot username for QR/deep-link login URLs. Falls back to a default if absent. |
|
||||
|
||||
Missing `AUTH_API_URL` produces `NG0201: No provider found for InjectionToken @marketplaces/auth AUTH_API_URL` at the first injection — including in unit tests, where any `TestBed` that constructs a component touching auth must provide it:
|
||||
|
||||
```ts
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: AUTH_API_URL, useValue: 'https://test.local' }],
|
||||
});
|
||||
```
|
||||
|
||||
## 3. What is in the package
|
||||
|
||||
Two independent auth mechanisms. They deliberately share no state — a customer QR scan never authenticates an admin session or vice versa (distinct cookies, signals, guards, interceptors).
|
||||
|
||||
### `telegram/` — live today
|
||||
|
||||
Telegram QR/session auth against `{AUTH_API_URL}/users/sessions`. One backend endpoint set, used by both customer and admin login; only *storage* differs.
|
||||
|
||||
| Export | What it is |
|
||||
|---|---|
|
||||
| `AuthService` | Customer session. Signals: `session`, `status`, `isAuthenticated`, `showLoginDialog`, `displayName`. Methods: `checkSession()`, `createWebSession()`, `requestLogin()`, `hideLogin()`, `logout()`, `onTelegramLoginComplete()`, `getTelegramAppLoginUrl()`. Cookie `webSessionID`, `SameSite=Lax`. |
|
||||
| `AdminAuthService` | Admin session. Same signal/method shape plus `getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()` (reserved for when the backend issues admin JWTs) and `devBypassLogin()` (no-ops outside dev mode). Cookie `adminSessionID`, `SameSite=Strict`. |
|
||||
| `TelegramSessionApiService` | Thin HTTP client + response normalization. Holds no state, writes no cookies. |
|
||||
| `adminAuthGuard` | `CanActivateFn` — allows if the admin session is authenticated, otherwise opens the login dialog. |
|
||||
| `adminAuthHeadersInterceptor` | Attaches `AdminWebSessionID` (and `Authorization: Bearer` when a token exists) to admin-gated paths only (`/admin/`, `/backoffice/`, `/builder/`, `/media/`). Never touches customer requests. |
|
||||
| `AuthSession`, `WebSessionStart`, `AuthStatus`, `AdminAuthStatus` | Wire/state types. |
|
||||
|
||||
Typical usage:
|
||||
|
||||
```ts
|
||||
import { AuthService, AdminAuthService, adminAuthGuard, adminAuthHeadersInterceptor } from '@marketplaces/auth';
|
||||
|
||||
// routes
|
||||
{ path: 'backoffice', canActivate: [adminAuthGuard], loadComponent: ... }
|
||||
|
||||
// http
|
||||
provideHttpClient(withInterceptors([adminAuthHeadersInterceptor, ...]))
|
||||
|
||||
// component
|
||||
private readonly auth = inject(AuthService);
|
||||
readonly isLoggedIn = this.auth.isAuthenticated; // signal
|
||||
```
|
||||
|
||||
**Security note:** the Telegram session API has no concept of "admin." The frontend cannot distinguish an admin Telegram session from a regular one — it only decides *where to store* the result. Real admin authorization must be enforced server-side on every admin request. See [TRACK-S](backend/BACKEND-INTEGRATION.md).
|
||||
|
||||
### `ed25519/` — prepared, backend not shipped
|
||||
|
||||
Challenge/response admin auth: `GET /api/admin/auth/challenge` → sign nonce with a device-local non-extractable Ed25519 key → `POST /api/admin/auth/verify` → JWT pair. Calling these today 404s/connection-errors, which surfaces as the `backend-unavailable` error screen. Nothing is mocked.
|
||||
|
||||
| Export | What it is |
|
||||
|---|---|
|
||||
| `AuthFacade` | The surface components should use. `isAuthenticated`, `status`, `role`, `loginPhase`, `lastError`; `login(redirectTo?)`, `logout(redirectTo?)`, `restoreSession()`, `can(permission)`. |
|
||||
| `Ed25519AuthService` | Low-level flow orchestrator (exported under this name so it doesn't collide with the telegram `AuthService`). |
|
||||
| `SessionService` | JWT/refresh pair + derived claims, auto-refresh before expiry. |
|
||||
| `Ed25519KeypairService` | WebCrypto Ed25519 keypair in IndexedDB. Private key is non-extractable and never leaves the device. |
|
||||
| `PermissionService` | Derives permissions from the JWT `role` claim. UI-only gate. |
|
||||
| `JwtService` | Decode only, never verification — the frontend has no trusted key; signature checking is the backend's job on every request. |
|
||||
| `Ed25519VerificationService` / `NoopEd25519VerificationService` | Abstract seam + fail-closed default binding. |
|
||||
| `AdminRole`, `Permission`, `ROLE_PERMISSIONS`, `AuthChallenge`, `AuthTokenPair`, `JwtClaims`, `AuthError`, `AuthErrorCode`, … | Types and wire contracts. |
|
||||
|
||||
Bind the verification seam in `app.config.ts`:
|
||||
|
||||
```ts
|
||||
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
|
||||
```
|
||||
|
||||
## 4. What deliberately stayed in the app
|
||||
|
||||
`AdminPermissionsService` and `requireAdminPermission` live in `marketplaces` (`src/app/core/admin-auth/`), not in the package. They read this app's mock Users domain to derive a permission set — app-specific, not a portable auth concern. If another project needs permission gating it should use the package's `PermissionService` (JWT-claim-driven) instead.
|
||||
|
||||
## 5. `@marketplaces/payment`
|
||||
|
||||
Published at `0.1.0` but **scaffold only** — no implementation yet, nothing exported, and `marketplaces` does not depend on it. `core/finance` and `core/pricing` still live in the app. Payment business logic is server-side by design (see [Phase 1](backend/BACKEND-INTEGRATION.md) and [Phase 7](backend/BACKEND-INTEGRATION.md)); the eventual package is a thin client for FX/pricing/checkout gateways.
|
||||
|
||||
## 6. Making a change to a package
|
||||
|
||||
1. Clone [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git).
|
||||
2. Edit under `packages/auth/src` (or `packages/payment/src`), export from `index.ts`.
|
||||
3. `npx changeset` at the repo root — pick the package and bump type, write one line about the change.
|
||||
4. Commit, push, open a PR to `main`. CI builds, tests, and rejects the PR if the changeset is missing.
|
||||
5. On merge, CI rebuilds and force-pushes `release/auth` / `release/payment`, and opens a "Version Packages" PR if there are unreleased changesets.
|
||||
6. In `marketplaces`, run `npm update @marketplaces/auth`, then the build + test suite before merging.
|
||||
|
||||
Never commit to a `release/*` branch — they are generated and force-pushed. Never edit `node_modules/@marketplaces/*` — overwritten on every install.
|
||||
479
docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md
Normal file
479
docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Product Plan v3.1 — Delivery Plan (Phases → Sprints → Todos)
|
||||
|
||||
Companion to [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Every gap identified there is assigned here exactly once. Wire contracts for every `[BE]`/`[BOTH]` phase and track below are written up in [docs/backend/](backend/README.md) — hand that directory to whoever builds the backend.
|
||||
|
||||
**No calendar dates.** The plan itself (§12) refuses invented dates and fixes *sequence + exit criteria* instead. This document does the same. Sprints are ordered units of work, not two-week promises. Sizes are relative: **S** / **M** / **L** / **XL**.
|
||||
|
||||
**Ownership tags:** `[FE]` this repo · `[BE]` backend/platform service · `[BOTH]` coordinated contract change · `[DEC]` decision, no code.
|
||||
|
||||
**Deviation from the plan's own order, and why:** the plan sequences P0-C (external ingestion) before P0-D (catalog integrity). We swap them. External order ingestion maps `externalSKU → internal offer` (§5.1), and `Offer` does not exist yet — ingestion has nothing to map onto until the Product/Offer split ships. Everything else follows the plan's ordering.
|
||||
|
||||
---
|
||||
|
||||
## Phase map
|
||||
|
||||
| Phase | Name | Plan ref | Gate |
|
||||
|---|---|---|---|
|
||||
| **0** | Unblock & seams | — | Decisions answered; every admin domain swappable |
|
||||
| **1** | Money & payment truth | P0-A, §2.3 §3.3 §3.8 §7 | An order total is explainable from data |
|
||||
| **2** | Orders canonical + notifications | P0-B, §2.8 §2.10 §3.5 | Paid order appears and notifies without refresh |
|
||||
| **3** | Catalog integrity + fulfillment | P0-D, §2.1 §2.4 §3.6 | Any published offer is genuinely buyable and fulfillable |
|
||||
| **4** | External order ingestion | P0-C, §5 §3.7 | External purchase lands in Orders, no duplicates |
|
||||
| **🚦** | **PRODUCTION LAUNCH GATE** | §3 LAUNCH BLOCKERS, §13.2 | All P0 closed and evidenced |
|
||||
| **5** | Seller Portal | P1-A, §2.2 | Seller runs own offers and orders in scoped UI |
|
||||
| **6** | Server cart + checkout session | P1-B, §2.5 §2.6 | Client price never trusted; repeat-safe |
|
||||
| **7** | Payments hardening + reconciliation | P1-C, §2.7 §7.3 | Internal vs provider matched, mismatches visible |
|
||||
| **8** | Identity & messaging | §2.9 §3.4 §14 | VK/MAX/Telegram linked; bot collects delivery |
|
||||
| **9** | Tenant registry, domains, releases | P2-A, §4.3 §8 | New marketplace launched with no hardcode |
|
||||
| **10** | Tenant content modules (Gorbushka) | P2-B, §11 | Content tenant on same runtime/backoffice |
|
||||
|
||||
**Parallel tracks** (start early, run across phases): **A** Analytics pipeline · **S** Security/RBAC/audit · **P** Partner provisioning API (P1–P3 gate Phase 1) · **Q** QA & E2E · **N** API namespace migration · **Z** Pre-existing repo debt.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Unblock & seams
|
||||
|
||||
Nothing downstream can be honestly estimated until this closes. Two sprints: one is other people answering questions, one is work we can do today with no answers.
|
||||
|
||||
### Sprint 0.1 — Decisions `[DEC]`
|
||||
|
||||
**Answered 2026-08-17.** Kept as a record — the reasoning behind each answer still governs how later phases get built.
|
||||
|
||||
- [x] **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository's team owns the frontend and the contract set itself, which is why the contracts are the primary handoff artifact rather than a side deliverable.
|
||||
- [x] **Unfreeze the payment chain — YES.** `BACKEND-API-REFERENCE.md §7`'s do-not-modify note no longer applies. Phases 1, 6, 7 are unblocked to proceed.
|
||||
- [x] **External marketplaces — no fixed list.** User: connectors must onboard "our new ones, partners, new, etc." as they arrive — i.e. the platform's own future partner integrations, not a fixed enumeration of named third-party marketplaces to build against up front. **Consequence for Phase 4:** build the Sprint 4.1 connector framework generic/config-driven (auth, mapping, retry, dead-letter as pluggable per-connector config) so a new partner is an onboarding, not a code change. Sprint 4.2 ("one sprint per named marketplace") is retired as written — replaced by a generic "add connector" runbook, sized once the framework exists, not per-name up front.
|
||||
- [x] **FX rate source — build our own, as a safety gate.** User: "not yet, lets handle from our side, if they dont" — no external provider is committed yet. Backend owns FX computation in-house as the authoritative source; the `source` field in the Phase 1 contract stays provider-agnostic and can point at an internal computed rate as legitimately as an external adapter. This *is* the "configured fallback" the contract doc's §3.2 already describes — now the default, not the fallback.
|
||||
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
|
||||
- [x] **Multi-seller orders — unified**, judgment call as instructed. One `Order` per checkout regardless of seller count, split into per-seller `Fulfillment` groups internally (matches §2.8's "canonical Order regardless of source" and §2.5's cart-level seller-grouping requirement without introducing parallel parent orders). Applies to Phase 3's `Offer` model, Phase 5's Seller Portal order view (scoped to that seller's fulfillment groups within the shared order), and closes the three-document disagreement flagged in Z16.
|
||||
- [x] **"Fixed 5-second payment" claim — resolved as a non-issue.** User: "make polling 5 secs." Checked `config/constants.ts`: `PAYMENT_POLL_INTERVAL_MS` is already `5000`. This is a poll *cadence* against real provider status each tick, not an artificial fixed-delay-then-success — stays compliant with the plan's §3.2 prohibition. No code change needed; confirmed and left as-is.
|
||||
- [x] **API namespace migration — adopt for new endpoints only, no forced migration.** User: unclear on the question, deferred to "what's recommended," noted "APIs are our domains" (i.e. we control the surface, lower urgency to force a big-bang rename). Recommendation taken: `backend/BACKEND-INTEGRATION.md` already specifies all-new endpoints under the `/api/v2/...` namespace family. Legacy endpoints (`/cart`, `/orders`, `/items`, etc.) stay as-is until a dedicated migration sprint is scheduled — not blocking Phase 1.
|
||||
- [x] **Document version — v3.1 is canonical.** The source file's internal "3.0" version block is stale/wrong; all our docs treat v3.1 as authoritative going forward.
|
||||
|
||||
**Exit:** all nine answered in writing.
|
||||
|
||||
### Sprint 0.2 — Seams and type reconciliation `[FE]` — runs regardless of answers
|
||||
|
||||
- [ ] Add DI tokens to the 9 admin domains that have none: Orders, Products, Users, Transactions, Monitoring, Moderation (+ derived Customers, Analytics). **M** — hard prerequisite for every `[BE]` swap in Phases 1–7.
|
||||
- [ ] Reconcile `AdminRole` — defined twice with unrelated shapes (auth string-union vs. Users-page display interface). **S**
|
||||
- [ ] Reconcile the two `Category` types, both fed by the same `/category` response, both in use. **S**
|
||||
- [ ] Resolve `SellerConfig` (bootstrap) vs. `Seller`/`SellerBranding` (domain) — pick one or document the mapping. Blocks Phase 5. **S**
|
||||
- [ ] Build the feature-flag / capability-guard service an existing ADR already promises; migrate the hand-rolled `sellerManagement.enabled` check onto it. **S**
|
||||
- [ ] Build the centralized error-handling layer (`core/error-handling/`, `core/interceptors/` are `.gitkeep`-only today): error-envelope interceptor + 429 handling. **M** `[BOTH]` — envelope shape needs backend agreement.
|
||||
- [ ] Fix `toAuthErrorShape()` to read a body-level code, not HTTP status alone — the built "session expired" / "invalid signature" screens are currently dead UI. **S**
|
||||
- [ ] Bind mock implementations to `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY`, or delete the dead mock branch. Today both silently ignore `useMockData`. **S**
|
||||
|
||||
**Exit:** any admin domain can be pointed at a real backend by swapping one provider.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Money & payment truth (P0-A)
|
||||
|
||||
Closes §3.3 and §3.8, and half of the §13.1 acceptance table. The single highest-value phase: it is what makes totals explainable to a bank.
|
||||
|
||||
### Sprint 1.1 — Money model `[BOTH]`
|
||||
|
||||
- [ ] `Money = { amountMinor: int, currency }` end to end. Kill float arithmetic in `CurrencyRatesService.convert()`. **L**
|
||||
- [ ] Currency minor-units + rounding rules table (RUB/USD/EUR/AMD at minimum). **M**
|
||||
- [ ] Delete browser-owned rates: remove `currencyRates.v1` from `localStorage` and the hardcoded `DEFAULT_RATES` fallbacks (`USD: 0.011`, `AMD: 4.3`). **S**
|
||||
- [ ] Remove the admin-typed rate editor from Admin Settings once a real source exists. **S**
|
||||
|
||||
### Sprint 1.2 — FX quote + rate source `[BE]` + `[FE]`
|
||||
|
||||
- [ ] `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` entity + endpoint. **M**
|
||||
- [ ] Rate-source adapter behind an interface; concrete provider pluggable (§7.1). **M**
|
||||
- [ ] Stale/outlier quote rules; checkout **blocks** or uses an explicitly configured fallback. **M**
|
||||
- [ ] `PriceBook`: offer base currency + allowed display/checkout currencies per tenant. **M**
|
||||
|
||||
### Sprint 1.3 — Price snapshot + server-authoritative amount `[BOTH]` — needs the freeze lifted
|
||||
|
||||
- [ ] `PriceSnapshot { offerId, amount, currency, fxQuoteId, capturedAt }`, immutable. **L**
|
||||
- [ ] Server computes and validates the charged amount. Stop trusting `CartPaymentRequest.amount` and the per-item `price[]` array from the browser. **L** — the plan's §2.5 headline requirement.
|
||||
- [ ] Old orders never recalculated when a rate updates. **S**
|
||||
- [ ] Backoffice "total formula" panel: lines × qty − discounts + delivery + fees, plus the FX quote used (§7.2). **M**
|
||||
- [ ] `PriceHistory` on offer price and stock, with author/source (§2.1). **M**
|
||||
|
||||
### Sprint 1.4 — Payment timeline `[BE]` + `[FE]`
|
||||
|
||||
- [ ] Explicit state machines: `PaymentIntent` (created→pending→authorized/paid→failed/cancelled), `Payment` (received→confirmed→captured/settled→refunded), `Order` (pending_payment→paid→processing→fulfilled). **L**
|
||||
- [ ] Persist `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` per transition. **M**
|
||||
- [ ] Webhook entrypoint with signature verification + idempotency (§2.7). **L**
|
||||
- [ ] Idempotency keys on checkout, payment and order creation. Zero `idempot*` exists today. **M**
|
||||
- [ ] Replace client-polled status signals with server truth; keep polling only as a UI fallback. **M**
|
||||
- [ ] Keep the current honest behaviour: no artificial delay. Already compliant — protect it with a test. **S**
|
||||
|
||||
**Exit criteria (plan's own):** currency converts correctly; payment timeline reconstructable from provider events; every total explainable from `SKU/qty/delivery/discount/FX`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Orders canonical + notifications (P0-B)
|
||||
|
||||
### Sprint 2.1 — Canonical order model `[BOTH]`
|
||||
|
||||
- [ ] `Order` header: `marketplaceId, source, customer, currency, subtotal, discounts, delivery, total, paymentStatus, orderStatus`. **L**
|
||||
- [ ] `OrderLine` with `offerId, sellerId, skuSnapshot, titleSnapshot, qty, unitPriceMinor, lineTotalMinor, priceSnapshotId`. **M**
|
||||
- [ ] `OrderEvent` timeline: created, paid, seller notified, accepted, fulfilled, cancelled, refunded (§2.8). Closes our own "Real order audit trail" TODO. **M**
|
||||
- [ ] Real `AdminOrdersApiGateway` replacing the 24-row static seed with no create path. **L** `[BE]`
|
||||
- [ ] Admin order actions: assign, resend notification, replay sync, cancel/refund by permission, comment, export. **M**
|
||||
- [ ] `OrderContactSnapshot` — name/contacts frozen at order time, immune to later profile edits (§2.9). **S**
|
||||
|
||||
### Sprint 2.2 — Event bus + Notification Center `[BE]` + `[FE]`
|
||||
|
||||
- [ ] Platform event bus emitting `order.created`, `order.paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested/completed`, `external_order.imported`. **L**
|
||||
- [ ] `Notification` entity: `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link**. **M**
|
||||
- [ ] `DeliveryAttempt` log per external channel — a Telegram/email failure must never lose the internal notification (§2.10). **M**
|
||||
- [ ] Backoffice Notifications section: unread queue, incidents, filter by marketplace and event type. Missing entirely from our nav today. **M**
|
||||
- [ ] Repoint `AdminOrderWatcherService` from polling to the event stream. Feature is already built and inert — this is what switches it on. **S**
|
||||
|
||||
**Exit:** a paid order appears in backoffice without manual refresh, with deep link and seller/source.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Catalog integrity + fulfillment (P0-D)
|
||||
|
||||
Biggest structural change in the whole programme. Everything about multi-seller commerce hangs off it.
|
||||
|
||||
### Sprint 3.1 — Product / Offer split `[BOTH]`
|
||||
|
||||
- [ ] Introduce `Offer/Listing { id, marketplaceId, sellerId, variantId, sellerSku, priceMinor, currency, stockPolicy, status, publishedAt }`. **XL** — does not exist in any form today.
|
||||
- [ ] Move price, stock, currency and status off `Product` onto `Offer`. **L**
|
||||
- [ ] Formalise `Product` / `Variant` / `SKU` / `Category` (with `attributesSchema`, SEO) as content-only. **L**
|
||||
- [ ] Unify the admin mock product domain with the live storefront `Item` domain — two unrelated shapes today. **L**
|
||||
- [ ] Offer lookup in backoffice by internal SKU, seller SKU, product ID or external mapping (§2.1 "готово, когда"). **M**
|
||||
|
||||
### Sprint 3.2 — Lifecycle, import, inventory `[BOTH]`
|
||||
|
||||
- [ ] `draft → moderation → published → paused/archived` for both product and offer; wire the existing mock Moderation module to it. **M**
|
||||
- [ ] Bulk import CSV/API: required-field validation, **error preview before apply**. Nothing exists (current "bulk" is Admin Categories edit actions only). **L**
|
||||
- [ ] `InventoryRecord`: `available` / `reserved` / `sold` counted separately. **L**
|
||||
- [ ] Reservations at checkout or pre-payment per strategy, with TTL. **M**
|
||||
- [ ] Idempotent upsert for seller feed stock updates; repeat webhook must not double-decrement. **M**
|
||||
- [ ] Oversell → dedicated incident queue, never silently hidden (§2.4). **M**
|
||||
|
||||
### Sprint 3.3 — Fulfillment + executability `[BOTH]`
|
||||
|
||||
- [ ] `Fulfillment` entity: manual / warehouse / pickup / digital; `status, assignedTo, issuedAt/shippedAt`, evidence where applicable. One `fulfil*` reference exists in the entire codebase today. **L**
|
||||
- [ ] Publish-time executability validation — an offer that cannot actually be fulfilled cannot be published (§3.6). **M**
|
||||
- [ ] Explicit test proving there is **no** inspector-detection branch anywhere: same production flow for every buyer (§3.6, §10.2, §13.2 last item). **S**
|
||||
- [ ] Multi-seller cart grouping by seller and fulfillment rules — currently undefined behaviour (§2.5). **M** — **Sprint 0.1 decision (2026-08-17): unified.** One `Order` per checkout regardless of seller count; group lines into per-seller `Fulfillment` entries internally, no parallel parent orders.
|
||||
|
||||
**Exit:** any published, available offer really passes order → fulfillment.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — External order ingestion (P0-C)
|
||||
|
||||
Zero percent built today. **Sprint 0.1 decision (2026-08-17): no fixed marketplace list** — connectors onboard "our new ones, partners, new, etc." as they arrive, not a pre-named enumeration. Sprint 4.2 is retired as originally written ("one sprint per named marketplace") and replaced with a generic onboarding runbook — Sprint 4.1's framework is now the deliverable that matters, sized to be genuinely config-driven rather than one-off per provider.
|
||||
|
||||
### Sprint 4.1 — Connector framework `[BE]`
|
||||
|
||||
- [ ] `Connector` + `ConnectorCredentialRef` in secret storage, scoped per marketplace/seller. **M**
|
||||
- [ ] Inbound: webhook where the provider supports it, polling fallback with cursor/since. **L**
|
||||
- [ ] `RawExternalEvent` — persist the raw payload before parsing, for traceability. **S**
|
||||
- [ ] Normalizer: external payload → canonical `ExternalOrderEvent` → internal `Order`. **L**
|
||||
- [ ] `ExternalOrderMapping`: `externalSellerId / externalProductId / externalSKU → internal seller/offer`. **L**
|
||||
- [ ] Idempotency on `source + externalOrderId/eventId`; a repeat must not create a duplicate order. **M**
|
||||
- [ ] Exponential retry, `DeadLetter`, manual replay from backoffice. **M**
|
||||
- [ ] **Unmatched queue** for events with no SKU mapping. **M**
|
||||
- [ ] Status/fulfillment push back to the external marketplace where its API allows (§5.2 step 8). **M**
|
||||
- [ ] **Config-driven adapter contract** — a new partner connector is authored as configuration (auth type, field mapping, rate limits) against the Sprint 4.1 framework, not a bespoke integration each time. **L** — this is what "no fixed list" requires structurally.
|
||||
|
||||
### Sprint 4.2 — Connector onboarding runbook `[BE]` — repeats per new partner, no longer named up front
|
||||
|
||||
- [ ] Generic onboarding checklist against the Sprint 4.1 framework: auth, endpoint mapping, rate limits, sandbox verification. **M each**, sized down from **L** now that the framework absorbs the bespoke work.
|
||||
|
||||
### Sprint 4.3 — Connector observability `[FE]` + `[BE]`
|
||||
|
||||
- [ ] Backoffice **Integrations** section (missing from our nav): connectors, payment providers, FX sources, messaging. **M**
|
||||
- [ ] Per-connector health: last success, lag, errors, rate limit, backlog, unmatched mapping. **M**
|
||||
- [ ] Trace id on every connector error, visible in backoffice (§5.2 SLA). **S**
|
||||
- [ ] SLA instrumentation: webhook 99% under 60s; polling ≤ interval + 60s; **0** duplicate orders. **M**
|
||||
|
||||
**Exit:** an external purchase creates/updates an order automatically, never duplicates, and notifies the responsible manager.
|
||||
|
||||
---
|
||||
|
||||
## 🚦 PRODUCTION LAUNCH GATE
|
||||
|
||||
Per §3 "LAUNCH BLOCKERS" and the §13.2 checklist. Do not schedule a launch before every line is green **and evidenced by a test, not an assertion**.
|
||||
|
||||
- [ ] All P0 closed and confirmed by tests
|
||||
- [ ] Production analytics collecting real events (Track A)
|
||||
- [ ] Catalog contains only genuinely available/publishable offers
|
||||
- [ ] Seller permissions verified (Phase 5 or enforced-empty)
|
||||
- [ ] cart → checkout → payment → order end-to-end smoke passed
|
||||
- [ ] Webhook signatures, idempotency, retry verified
|
||||
- [ ] External connector reconciliation passed
|
||||
- [ ] FX source live, stale-quote policy verified
|
||||
- [ ] Notification delivery + fallback verified
|
||||
- [ ] Refund flow + reconciliation smoke passed
|
||||
- [ ] Domains/SSL/health checks green (Phase 9)
|
||||
- [ ] Backup/rollback exists
|
||||
- [ ] Audit enabled (Track S)
|
||||
- [ ] **No branch anywhere alters commerce flow based on who the buyer appears to be**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Seller Portal (P1-A)
|
||||
|
||||
A placeholder page with a `false` flag and zero backend bytes today. Note: the enabled code path has **never been exercised even once** — every prior verification ran with the flag at its real value.
|
||||
|
||||
### Sprint 5.1 — Seller foundation `[BOTH]`
|
||||
- [ ] `SellerOrganization`, `SellerUser`, `SellerMarketplaceMembership`, `SellerIntegration`. **L**
|
||||
- [ ] Onboarding: organisation, credentials/profile, contacts, marketplace applications, moderation status. **L**
|
||||
- [ ] Backoffice **Sellers** section (missing from nav): organisations, applications, roles, status, listings, integration health. **L**
|
||||
|
||||
### Sprint 5.2 — Seller working surfaces `[FE]` + `[BE]`
|
||||
- [ ] Catalog: create/edit products & offers, media, attributes, submit for moderation, bulk import. **L**
|
||||
- [ ] Prices & Stock: mass edit, API/feed sync, change history, sync errors. **L**
|
||||
- [ ] Orders: new, confirm, pick/issue/ship, cancel, return, SLA, comments. Per the unified-orders decision (Sprint 0.1), this view is scoped to *this seller's* `Fulfillment` group within each shared `Order`, not a separate seller-owned order. **L**
|
||||
- [ ] Finance: accruals, commissions, refunds, settlement/payout register, report export. **L**
|
||||
- [ ] Team: `SELLER_OWNER`, `SELLER_CATALOG_MANAGER`, `SELLER_ORDER_MANAGER`, `SELLER_FINANCE_VIEWER`, `SELLER_VIEWER`. **M**
|
||||
- [ ] Integrations: API credentials, webhook/feed status, external SKU mapping, sync logs. **M**
|
||||
|
||||
### Sprint 5.3 — Seller isolation `[BE]` + `[Q]`
|
||||
- [ ] A seller cannot see another seller's products, orders, customers, finance or API keys — enforced backend-side, tested. **M**
|
||||
- [ ] Bank/payment detail changes: step-up auth + audit event + approval when maker/checker is on. **M**
|
||||
- [ ] Seller staff permissions verified backend-side regardless of UI visibility. **M**
|
||||
- [ ] First-ever fixture test of the seller-management enabled state. **S**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Server cart + checkout session (P1-B)
|
||||
|
||||
Partly pulled forward into Sprint 1.3 (server-authoritative amount). This phase completes the move.
|
||||
|
||||
### Sprint 6.1 — Server cart `[BOTH]`
|
||||
- [ ] `Cart` / `CartLine` server-side, keyed on `offerId`. Replaces `localStorage` + Telegram CloudStorage. **L**
|
||||
- [ ] Idempotent add/update/remove; quantity validated against stock and seller rules. **M**
|
||||
- [ ] Price-refresh: cart surfaces price changes before checkout and requires explicit confirmation when the total moved. **M**
|
||||
- [ ] Guest cart via session token; authenticated cart bound to customer account. **M**
|
||||
- [ ] Expiration: inactive carts cleared, reservations released on TTL. **S**
|
||||
|
||||
### Sprint 6.2 — Checkout session `[BOTH]`
|
||||
- [ ] `CheckoutSession` entity. `features/website/checkout/` is an empty directory today; checkout lives in a 751-line cart popup. **XL**
|
||||
- [ ] Server re-validates offers and stock at checkout start. **M**
|
||||
- [ ] Contact requirements enforced by tenant policy: email and/or phone verifiable (§2.6 step 4). **M**
|
||||
- [ ] Clear total breakdown shown to the customer. **M**
|
||||
- [ ] `PaymentIntent` via provider adapter; repeat click must not create a second intent. **M**
|
||||
- [ ] Guest-checkout on/off per tenant policy (§6.2). **S**
|
||||
- [ ] `DeliveryOption` entity. **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Payments hardening + reconciliation (P1-C)
|
||||
|
||||
### Sprint 7.1 — Refunds `[BOTH]`
|
||||
- [ ] `Refund` as a first-class operation with reason, actor and order-line linkage. `requestRefund(id)` is a mock method today. **L**
|
||||
- [ ] Partial refunds; `refunded / partially_refunded` states. **M**
|
||||
|
||||
### Sprint 7.2 — Reconciliation `[BE]` + `[FE]`
|
||||
- [ ] `ReconciliationRecord`; match on `providerPaymentId` / merchant reference / amount+currency fallback (§7.3). **L** — zero `reconcil*` in the codebase today.
|
||||
- [ ] Classify: unmatched, duplicate, amount mismatch, status mismatch. **M**
|
||||
- [ ] Backoffice **Payments & Finance** section (missing from nav): payments, refunds, reconciliation queue, unmatched events, settlements. **L**
|
||||
- [ ] Controlled resolution with full audit trail. **M**
|
||||
- [ ] Settlements / payout register. **L** — zero `settlement*` today.
|
||||
|
||||
### Sprint 7.3 — Provider breadth `[DEC]` + `[BOTH]`
|
||||
- [ ] Decide additional providers beyond the current QR/card flow (wallets, BNPL) — open business question. **DEC**
|
||||
- [ ] Provider adapter interface so a new provider is a plug-in, not a rewrite. **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Identity & messaging (§2.9, §3.4, §14)
|
||||
|
||||
**Sprint 0.1 decision (2026-08-17): VK ID first, then everything else** ("do all after vk"). Order below is resequenced accordingly — VK ID moved ahead of OTP.
|
||||
|
||||
### Sprint 8.1 — Customer identity core `[BOTH]`
|
||||
- [ ] `Customer`, `ExternalIdentity`, `ContactMethod`, `Verification`, `Consent`. **L**
|
||||
- [ ] Telegram demoted from sole identity to one provider among several. **M**
|
||||
- [ ] `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt`. **S**
|
||||
- [ ] Backoffice **Customers** on real data: profiles, verified contacts, orders, consent. **M**
|
||||
- [ ] Sensitive profile changes logged. **S**
|
||||
|
||||
### Sprint 8.2 — VK ID `[BOTH]` — new in v3.1, now first per Sprint 0.1
|
||||
- [ ] OAuth 2.1/PKCE completed **backend-side**; link external identity to `Customer`. **L**
|
||||
- [ ] VK ID as the primary storefront social login. **M**
|
||||
- [ ] Repeat login must never create a duplicate customer. **M**
|
||||
- [ ] Identity-conflict handling → controlled resolution, never overwrite an existing binding (§14.3). **M**
|
||||
|
||||
### Sprint 8.3 — Email/phone OTP `[BOTH]` — after VK ID
|
||||
- [ ] Implement the approved [email/phone login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). **L**
|
||||
- [ ] Position it as recovery/fallback per v3.1 §14, not as the primary path. **S**
|
||||
|
||||
### Sprint 8.4 — MAX + Telegram bot channels `[BOTH]` — new in v3.1
|
||||
- [ ] `ContactChannel`, `BotConversationBinding`, `MessagingConsent`. **L**
|
||||
- [ ] MAX bot-assisted linking: one-time code, TTL, single-use, bound to marketplace + browser session. **L**
|
||||
- [ ] Provider secrets never reach the frontend; all bot updates handled idempotently. **M**
|
||||
- [ ] Bot adapters (VK / MAX / Telegram) normalised into one `MessagingEvent` keyed to `orderId`. **L**
|
||||
|
||||
### Sprint 8.5 — Notification Orchestrator + delivery conversation `[BE]` — new in v3.1
|
||||
- [ ] Orchestrator routes `order.paid` to the customer's chosen channel; the backoffice notification always fires regardless. **L**
|
||||
- [ ] Channel choice in checkout ("where should we send confirmation?"), recorded in `OrderContactSnapshot`; linking flow must not lose the cart or checkout session. **M**
|
||||
- [ ] Delivery Conversation State Machine: `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`. **L**
|
||||
- [ ] Bot collects city/address/recipient/phone/time window/comment; backend validates and snapshots into the order. **L**
|
||||
- [ ] **The bot must never change financial statuses** — delivery fields only, via Delivery Service. **M**
|
||||
- [ ] Follow-up rules per tenant; after N attempts hand off to a manager, no infinite spam. **M**
|
||||
- [ ] Manager handoff view: message history, current conversation state, accept handoff. **M**
|
||||
- [ ] Messenger unavailability creates a `DeliveryAttempt` error and triggers fallback — never blocks the order. **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Tenant registry, domains, releases (P2-A)
|
||||
|
||||
### Sprint 9.1 — Marketplace Registry `[BOTH]`
|
||||
- [ ] `Marketplace`, `MarketplaceDomain`, `MarketplaceFeatureSet`, `MarketplaceRevision`. **L**
|
||||
- [ ] Backoffice **Marketplaces** section (missing from nav): registry, type, status, domains, currencies, feature set, responsible manager. **L**
|
||||
- [ ] Onboarding wizard, all 8 steps of §4.3 (card → feature set → domains → design → roles → integrations → staging + smoke → production launch). **XL**
|
||||
- [ ] Lifecycle state machine `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, **showing which blocker prevents the next transition**. **L**
|
||||
- [ ] Marketplace dashboard (§4.2): GMV, paid orders, conversion, payment failure rate, orders needing action, seller moderation queue, low stock, unmatched events, integration health, domain/SSL/release status. **L**
|
||||
- [ ] Re-scope the [super-admin Phase 1 design](superpowers/specs/superuser.md) against this — it overlaps registry and audit. **M**
|
||||
- [ ] Consolidate `MarketplaceRef` vs. `TenantConfig` if a third marketplace-shaped type appears. **S**
|
||||
|
||||
### Sprint 9.2 — Domain automation `[BE]`
|
||||
- [ ] Hostinger DNS integration, all 7 endpoints from §8.2. Zero references exist today. **L**
|
||||
- [ ] Read current zone → snapshot/rollback payload → build and validate plan → apply only after production approval. **L**
|
||||
- [ ] **Never touch MX/SPF/DKIM/DMARC/CAA** without a separate task. **S**
|
||||
- [ ] Propagation, SSL and health verification; mark domain active only after checks pass. **M**
|
||||
- [ ] Backoffice **Domains & Releases** section (missing from nav). **M**
|
||||
|
||||
### Sprint 9.3 — Publish model `[BOTH]`
|
||||
- [ ] `draft → validation → preview → publish` with immutable published revisions; rollback creates a new revision (§8.3). **L**
|
||||
- [ ] Real builder persistence — today `apiEndpoints.builder` is an empty placeholder and "publish" only promotes a `localStorage` signal. **L**
|
||||
- [ ] CMS/static pages get a real backend write path (currently in-memory bootstrap only). **L**
|
||||
- [ ] Enforce that orders/payments/inventory ledger are **not** part of a content revision and never roll back with the storefront. **S**
|
||||
- [ ] Tenant resolution hardening: verified Host server-side, unknown Host → 404 with **no fallback tenant** (§6.1). **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Tenant content modules (P2-B, Gorbushka)
|
||||
|
||||
Only after Commerce Core is real. The plan is explicit that Gorbushka does not define the architecture.
|
||||
|
||||
### Sprint 10.1 — Directory content entities `[BOTH]`
|
||||
- [ ] `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings`. Only static pages exist today. **XL**
|
||||
- [ ] Every entity carries `marketplaceId`, audit, and publish/preview flow. **M**
|
||||
- [ ] Mall scheme / floors / pins UI. **L**
|
||||
- [ ] Rent listings + lead capture. **M**
|
||||
|
||||
### Sprint 10.2 — Gorbushka tenant config `[FE]`
|
||||
- [ ] Feature set per §11.1: CMS, shops, services, scheme, rent, news, SEO/media/domains **on**; catalog / seller portal / commerce **platform-ready but off**. **M**
|
||||
- [ ] Prove commerce can be switched on later without touching backend or storefront code. **M**
|
||||
|
||||
---
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
### Track A — Analytics pipeline (P1-D, §3.1 §6.3)
|
||||
|
||||
**Start at Phase 1, not last.** Longest lead time in the programme, and it is a P0 in the plan's own §3. There is no tracking infrastructure at all today — this is not a missing endpoint.
|
||||
|
||||
- [ ] **A1** Server-side event logging spine. **XL** `[BE]`
|
||||
- [ ] **A2** Traffic events: `session_started`, `page_view`, source/utm/referrer, unique users/sessions. **M**
|
||||
- [ ] **A3** Catalog events: `search`, `category_view`, `product_view`, `seller_view`. **M**
|
||||
- [ ] **A4** Commerce events: `add_to_cart`, `cart_view`, `checkout_started`, `payment_started`, `payment_success/failed`, `order_created`. **M**
|
||||
- [ ] **A5** Operations metrics: `order_paid_to_notification` latency, fulfillment time, connector lag, payment webhook lag. **M**
|
||||
- [ ] **A6** Quality metrics: frontend/backend errors, checkout validation failures, FX stale-rate blocks. **M**
|
||||
- [ ] **A7** Real funnel dashboard in backoffice, replacing the mock-composed Analytics facade. **L**
|
||||
- [ ] **A8** **Synthetic traffic technically separated** from production analytics — staging/test only, never presented as real visits (§3.1, §6.3). **M**
|
||||
- [ ] **A9** Real product view counts — the shipped "Views" column always renders `0`. Either bridge to the live storefront `Item.visits` or serve it from the real Products backend. **S**
|
||||
- [ ] **A10** Post-launch monitoring set (§13.3): checkout conversion, payment success/failure, webhook lag, order-notification lag, connector lag, FX quote age, unmatched reconciliation, stuck fulfillment. **L**
|
||||
- [ ] **A11** Trending search terms endpoint — `loadTrending()` is a stub returning `of(null)`. **S**
|
||||
|
||||
### Track S — Security, RBAC, audit (§4.4, §10)
|
||||
|
||||
**Gate on Phase 5 and on the launch gate.** Today the role model is decorative: types exist, nothing gates any button, page or action. Anyone who authenticates has full access.
|
||||
|
||||
- [ ] **S1** Enforce RBAC backend-side with tenant scope on every request. **L**
|
||||
- [ ] **S2** Implement the 17 roles across 3 scopes (5 platform / 7 marketplace / 5 seller). **L**
|
||||
- [ ] **S3** Frontend permission guards on routes and actions — currently zero. **M**
|
||||
- [ ] **S4** Audit log covering permissions, seller changes, catalog moderation, price, payment/refund, manual order actions, integrations, production launch. `audit` appears only as mock display fields today. **L**
|
||||
- [ ] **S5** Backoffice **Audit & Security** section (missing from nav): role changes, sensitive actions, login/security events, exports. **M**
|
||||
- [ ] **S6** Step-up authentication for sensitive financial actions. **M**
|
||||
- [ ] **S7** Rate limits and abuse controls on storefront/auth/provider endpoints; client-side 429 handling (zero today). **M**
|
||||
- [ ] **S8** Secret storage for provider/connector credentials, scoped per marketplace/seller. **M**
|
||||
- [ ] **S9** PII minimisation: store only necessary customer data, restrict access and export. **M**
|
||||
- [ ] **S10** Ed25519 admin auth backend — wired client-side, 404s today. Decide: build it, or drop it for the plan's conventional RBAC. **DEC** + **L**
|
||||
- [ ] **S11** HttpOnly session cookie (existing frontend-blocked TODO). **M**
|
||||
|
||||
### Track P — Partner provisioning API (added 2026-08-18)
|
||||
|
||||
Inbound partner API for programmatic merchant-hierarchy management. Contract: [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
|
||||
|
||||
**P1–P3 gate Phase 1.** They change the payments and tenant schemas, so they must land before Phase 1 is implemented — retrofitting a routing dimension onto a populated payments table costs far more than carrying it from the first row. P4 onward can run any time after.
|
||||
|
||||
- [ ] **P1** Add `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](backend/BACKEND-INTEGRATION.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/BACKEND-INTEGRATION.md)). Frozen at checkout-session creation, immutable after. **M**
|
||||
- [ ] **P2** Add `Company` and `Project` above `Marketplace`; `Marketplace` gains `companyId`/`projectId`/`externalReference` ([Phase 9 §1](backend/BACKEND-INTEGRATION.md)). **M**
|
||||
- [ ] **P3** Add `PaymentPoint` (one payment method per marketplace; `qr` and `card` both ship today) and backfill existing marketplaces per [Phase 9 §1.2](backend/BACKEND-INTEGRATION.md). **M**
|
||||
- [ ] **P4** Provisioning endpoints: create/read/status/disable for project, store, payment point, with cascading disable. **L**
|
||||
- [ ] **P5** Idempotency-Key handling: replay on identical body, `409` on same key + different body, in-flight collision, 24h retention. **M**
|
||||
- [ ] **P6** Partner credentials: public-key registration, node-scoped authority, signed-request verification, rotation with overlap, immediate revoke ([Track S §4.1](backend/BACKEND-INTEGRATION.md)). **L**
|
||||
- [ ] **P7** Read surfaces: full-hierarchy fetch, `externalReference` lookup, partner-scoped audit query. **M**
|
||||
- [ ] **P8** `PartnerProfile` config: required levels, level aliases, routing field names, rate tier, rotation window. Onboarding a partner must be a config row, not a deployment. **M**
|
||||
- [ ] **P9** TEST/LIVE partition: disjoint credentials, disjoint ids, `403` on cross-environment access. **M**
|
||||
- [ ] **P10** Partner OpenAPI spec generated from the implementation, plus documented error codes and published rate limits. **M**
|
||||
|
||||
### Track Q — QA & E2E (§13)
|
||||
|
||||
The plan's entire Definition of Done is end-to-end. We have **zero** E2E tests and ~32% statement / ~19% branch coverage across 11 spec files.
|
||||
|
||||
- [ ] **Q1** Stand up an E2E harness (Playwright or equivalent) — none exists. **L**
|
||||
- [ ] **Q2** Solve automated admin login; several past "verified live" claims were code-inspection only because `/edit` and `/backoffice` need Telegram login. **M**
|
||||
- [ ] **Q3** E2E: full §13.1 acceptance path — seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment. **XL**
|
||||
- [ ] **Q4** E2E: currency switch recalculates by FX quote — explicitly, `160 RUB` must not become `160 USD/AMD`. **M**
|
||||
- [ ] **Q5** E2E: repeat webhook and double-click create exactly one order. **M**
|
||||
- [ ] **Q6** E2E: external marketplace purchase imports and notifies. **M**
|
||||
- [ ] **Q7** Facade tests for cart/checkout, moderation, Orders, Products, Users, Transactions, Monitoring — the domains about to get real backends carry the most regression risk with the least coverage. **L**
|
||||
- [ ] **Q8** Regression pattern for reactive flag/config reads that must track `bootstrapRevision()` — this bug class already bit us once and was invisible until specifically hunted. **S**
|
||||
- [ ] **Q9** Set a justified coverage floor and a CI gate. Deliberately unset today. **M**
|
||||
- [ ] **Q10** One real screen-reader pass (NVDA/VoiceOver). Never performed on this codebase — every accessibility claim to date is automated tree inspection only. **M**
|
||||
|
||||
### Track N — API namespace migration (§9.3)
|
||||
|
||||
Cheapest now, more expensive every phase. Decision in Sprint 0.1.
|
||||
|
||||
- [ ] **N1** Adopt `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`. **L** `[BOTH]`
|
||||
- [ ] **N2** Migrate today's flat unversioned endpoints (`/cart`, `/orders`, `/items`, `/category`, `/searchitems`) plus the separate `qrApiUrl` host. **L**
|
||||
- [ ] **N3** Agree the structured error envelope; today no interceptor reads error bodies at all. **M** (implementation lands in Sprint 0.2)
|
||||
|
||||
### Track Z — Pre-existing repo debt
|
||||
|
||||
Not in the plan, but real. Fold into whichever phase touches the same surface.
|
||||
|
||||
- [ ] **Z1** Dark-mode selector does nothing — nothing reads `data-theme-mode`. **S**
|
||||
- [ ] **Z2** "Site Layout" selector has no effect — `layout.type` is edited but never read. **S**
|
||||
- [ ] **Z3** Footer "Contacts" link has no content behind it. **S**
|
||||
- [ ] **Z4** `SeoService.setItemMeta()` exists but is **never called** — product pages ship only site-wide meta. **S**
|
||||
- [ ] **Z5** `og:locale` hardcoded to `ru_RU` regardless of active locale. **S**
|
||||
- [ ] **Z6** No JSON-LD structured data, no sitemap generation. **M**
|
||||
- [ ] **Z7** Hardcoded Russian payment-description fallback (`'Покупка на Маркетплейсе'`) in a multi-tenant product. **S**
|
||||
- [ ] **Z8** Brand colours fail WCAG AA — `--border-color` at 1.24–1.42:1 against a 3:1 requirement; status colours fail 4.5:1 as text. **Needs theme-owner sign-off, not just a code fix.** **M**
|
||||
- [ ] **Z9** Literal hex `#cdd6d5` in `stars.component.scss:10` with no token behind it. **S**
|
||||
- [ ] **Z10** Two large lazy chunks unaddressed: `project-editor` (~1.0 MB), `catalog-container` (~330–375 kB). Profile under real backend latency, not instant mock responses. **M**
|
||||
- [ ] **Z11** `navigation.header` is editable in the builder with zero runtime consumer — needs a product decision, not a wiring fix. **DEC**
|
||||
- [ ] **Z12** `catalog.navigationMode` renders a deliberate placeholder; the mega-menu / carousel / left-nav variants it implies do not exist. **DEC**
|
||||
- [ ] **Z13** `sellerId` typed as bare `string` instead of the `UUID` alias used elsewhere. **S**
|
||||
- [ ] **Z14** No shared breadcrumb component; the only breadcrumb logic is a local signal in the catalog container. **S**
|
||||
- [ ] **Z15** Duplicate search models under two module paths. **S**
|
||||
- [ ] **Z16** Consolidate the eight cross-linked Seller Management documents onto the now-resolved decision (unified orders, Sprint 0.1, 2026-08-17) — at least three independently restated the question before it was answered. Do this **before** Phase 5 starts. **M**
|
||||
- [ ] **Z17** Angular 22 upgrade — researched, not started; needs a dependency fix and a Node bump. **Its own dedicated session, never bundled with feature work.** **M**
|
||||
|
||||
---
|
||||
|
||||
## Critical path
|
||||
|
||||
```
|
||||
Sprint 0.1 (decisions)
|
||||
└─> Sprint 0.2 (seams)
|
||||
└─> Phase 1 (money truth) ──────────────┐
|
||||
└─> Phase 2 (orders + notif) │
|
||||
└─> Phase 3 (offer split) │
|
||||
└─> Phase 4 (external ingestion)
|
||||
└─> 🚦 LAUNCH GATE
|
||||
Track A (analytics) ── starts at Phase 1, gates the launch ──┘
|
||||
Track S (RBAC/audit) ── starts at Phase 2, gates the launch ──┘
|
||||
Track Q (E2E) ── starts at Phase 1, evidences the gate ┘
|
||||
```
|
||||
|
||||
Phases 5–10 all sit behind the launch gate and can be resequenced by business priority. Phases 1–4 cannot.
|
||||
|
||||
**Single hardest dependency:** Phase 1 Sprint 1.3 needs the payment chain unfrozen. If that answer is "no", the programme stops at Sprint 0.2 and the plan's P0s cannot be delivered — that outcome should go back to them in writing, not be worked around.
|
||||
279
docs/PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md
Normal file
279
docs/PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Product Plan v3.1 — What They Want vs. What We Have
|
||||
|
||||
**Source:** `Marketplaces-Platform-Product-Plan-v3.1.pdf` (27 pages, RU). Version block inside still reads `3.0 / 17 августа 2026` — the filename says v3.1. Section 14 is the v3.1 addition (appended after the document's own conclusion).
|
||||
|
||||
**Our side, as verified in this repo:** Angular frontend only (426 `.ts` files). Sources for "what we have": [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md), [GAPS-AND-IMPROVEMENTS.md](../GAPS-AND-IMPROVEMENTS.md), and direct source inspection.
|
||||
|
||||
---
|
||||
|
||||
## 1. What they are actually asking for
|
||||
|
||||
One sentence: **stop building storefronts, build a platform** — a single multi-tenant commerce core where launching a new marketplace is a configuration act, not an engineering project.
|
||||
|
||||
Their own acceptance bar (§"ГЛАВНЫЙ КРИТЕРИЙ" and §13):
|
||||
|
||||
> A real product walks the whole path: seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment → reconciliation.
|
||||
|
||||
Three things the document is really about, under the product language:
|
||||
|
||||
1. **They do not trust our numbers.** Traffic counters, payment timings, order totals and currency amounts are all called out as unexplainable. §10.2 says it outright: don't fix appearance, fix the data.
|
||||
2. **They suspect demo behaviour in production.** "No fixed 5-second payment", "no synthetic traffic in production analytics", "no special branch for banks/inspectors" (§3.2, §3.1, §3.6, §10.2, and again in the launch checklist). This is an audit/compliance posture, not a feature request — a bank or NSPK is checking this platform.
|
||||
3. **Commerce Core is no longer optional.** In v3.0 language, Catalog/Seller Portal/Cart/Checkout/Payments/Orders stopped being "a possible extension" and became mandatory platform modules. Gorbushka is demoted to "one tenant scenario" (§11) — it does not define the architecture.
|
||||
|
||||
**Launch blockers they define (§3, "LAUNCH BLOCKERS"):** all P0s — money/FX, payment timeline, notifications, external order ingestion, price traceability, guaranteed fulfillability of published offers.
|
||||
|
||||
---
|
||||
|
||||
## 2. What is new in v3.1 vs v3.0
|
||||
|
||||
Everything in **§14 "Customer Identity и коммуникация после покупки"** (pages 26–27). Nothing else in the document is marked as changed.
|
||||
|
||||
| New in v3.1 | Detail | Our state |
|
||||
|---|---|---|
|
||||
| **VK ID as primary social login** | Backend completes OAuth 2.1/PKCE, links external identity to `Customer` | Zero. No `vk` reference anywhere in source; one `oauth` reference total. |
|
||||
| **MAX messenger bot** | Bot-assisted account linking via one-time code; official MAX Bot API | Zero. |
|
||||
| **Telegram demoted** | Kept, but as *one* identity provider among several | Today Telegram is the **only** login for both customers and admins. |
|
||||
| **Notification Orchestrator** | Routes `order.paid` to the customer's chosen channel; backoffice notification always fires even if the messenger is down | Zero. |
|
||||
| **Delivery Conversation State Machine** | `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`, bot collects delivery details, manager handoff | Zero. |
|
||||
| **Channel choice in checkout** | "Where should we send confirmation?" — VK / MAX / Telegram / email-SMS fallback, recorded in `OrderContactSnapshot` | Zero. |
|
||||
| **`ExternalIdentity` / `ContactChannel` / `BotConversationBinding` / `MessagingConsent`** | Four new entities | Zero. |
|
||||
|
||||
**Manager note:** §14 partially collides with our approved [email/phone OTP login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). v3.1 keeps email/phone but reduces them to *recovery/fallback* when a messenger is unavailable. Our in-flight work is still valid, but its priority drops below VK ID. Needs a call before that spec is implemented.
|
||||
|
||||
---
|
||||
|
||||
## 3. The differences — detailed
|
||||
|
||||
Legend: ✅ have · 🟡 partial / mock only · ❌ missing · ⚠️ conflicts with something we already decided.
|
||||
|
||||
### 3.1 Platform components (§1.1) — 8 named components, we have 2
|
||||
|
||||
| Plan component | Our state |
|
||||
|---|---|
|
||||
| Storefront Runtime | ✅ Bootstrap-driven, tenant-configured, no per-project fork. This is our strongest match to the plan. |
|
||||
| Platform Backoffice | 🟡 14 admin modules exist, but only **Categories** has a real HTTP backend. 9 of 11 admin domains inject their mock gateway directly — no DI seam to swap at all. |
|
||||
| Platform API | 🟡 Storefront catalog/search/cart-payment are live; everything admin-side is mock. |
|
||||
| Seller Portal | ❌ A static placeholder page, feature flag `false` by default, zero backend bytes, zero `HttpClient` reference. |
|
||||
| Workers / Event Processing | ❌ Nothing. No event bus, no retry, no dead-letter. |
|
||||
| Integration Hub | ❌ Nothing. Zero `reconcil*`, zero `idempot*` in the whole codebase. |
|
||||
| Domain Automation | ❌ Nothing. Zero `hostinger` references — the plan's §8.2 lists seven Hostinger DNS endpoints we have never touched. |
|
||||
| Marketplace Registry / Launch Center | ❌ Nothing shipped. Closest thing is our unshipped [super-admin Phase 1 design](superpowers/specs/superuser.md), which covers cross-tenant *viewing* but not registry/feature-set/launch. |
|
||||
|
||||
### 3.2 Catalog model (§2.1) — the biggest structural gap
|
||||
|
||||
The plan's core catalog idea is a **two-layer split**: `Product` (content card) vs. `Offer/Listing` (the seller's commercial proposition, which owns price, stock, currency, status). Order lines then snapshot the offer.
|
||||
|
||||
| Plan entity | Our state |
|
||||
|---|---|
|
||||
| `Product` / `Variant` / `SKU` | 🟡 Exists as admin mock + a separate live storefront `Item` domain. Two unrelated `Category` types, both fed by the same response, both in use. |
|
||||
| `Offer / Listing` | ❌ Does not exist. Price and stock hang off the product. Multi-seller pricing on one product card is not expressible. |
|
||||
| `PriceSnapshot` | ❌ Does not exist. |
|
||||
| `InventoryRecord` (available/reserved/sold) | ❌ Does not exist. No reservations, no TTL, no oversell queue. |
|
||||
| `PriceHistory` | ❌ Does not exist. |
|
||||
| Draft → moderation → published → paused/archived | 🟡 An admin Moderation module exists, on mock data. |
|
||||
| Bulk import CSV/API with pre-apply error preview | ❌ Only bulk *edit* actions inside Admin Categories. No import pipeline. |
|
||||
| "Storefront search/filters run on published data, not local mock arrays" | ⚠️ Directly aimed at us. `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` silently always resolve to the real API — but Search, wishlist/compare, cart contents and CMS are entirely `localStorage`. |
|
||||
|
||||
### 3.3 Money, FX and price traceability (§2.3, §3.3, §3.8, §7)
|
||||
|
||||
This is where the plan is most explicit, and where we most clearly do the forbidden thing.
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| `Money = amountMinor + currency`, **no float for money math** | ⚠️ We use plain `number` prices and float division/multiplication in `CurrencyRatesService.convert()`. |
|
||||
| Rates come from a configurable **external source** with `source`, `rate`, `timestamp`, `TTL` | ⚠️ Rates are **hand-typed by an admin** into Admin Settings and stored in **browser `localStorage`** (`currencyRates.v1`), with hardcoded fallbacks (`USD: 0.011`, `AMD: 4.3`). They never update and drift from market. |
|
||||
| `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` | ❌ Does not exist. |
|
||||
| Stale-quote control blocks checkout | ❌ Does not exist. |
|
||||
| Checkout writes an immutable price snapshot; old orders never recalculated | ❌ Does not exist. |
|
||||
| `PriceBook` (base currency + allowed display/checkout currencies) | ❌ Does not exist. |
|
||||
| Backoffice shows the total formula: lines × qty − discounts + delivery + fees, plus the FX quote used | ❌ Does not exist. |
|
||||
| Reconciliation of internal orders vs. provider transactions | ❌ Does not exist (`reconcil*` = 0 hits repo-wide). |
|
||||
|
||||
**Nuance worth telling them:** their §3.3 complaint is *"switching RUB/USD/AMD keeps the same number"*. Our storefront **does** convert the displayed number. Their real, unstated problem is the one our own [§12.7](../BACKEND-API-REFERENCE.md) already flagged: the **charged** amount is computed client-side in RUB and posted to `/cart` as `amount`, so bank settlement totals don't reconcile against order counts. We agree with the plan here — we raised it first.
|
||||
|
||||
### 3.4 Cart and Checkout (§2.5, §2.6) — ⚠️ head-on conflict with a frozen system
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Cart is **server-side**, keyed on `offerId` | ⚠️ Cart is `localStorage` + Telegram CloudStorage. There is no backend cart at all. |
|
||||
| "Client never sends a trusted price to the server" | ⚠️ `CartPaymentRequest` sends `amount`, `currency`, and a per-item `price` array from the browser. This is exactly the pattern the plan forbids. |
|
||||
| Checkout is a **server session** producing a price snapshot + contact snapshot | ❌ Checkout is an inline popup in `pages/cart/cart.component.ts` (751 lines). `features/website/checkout/` is an empty directory. |
|
||||
| Idempotent order creation keyed on the payment | ❌ `/orders` is called fire-and-forget after payment success. Zero `idempot*` in the codebase. |
|
||||
| Backend re-validates offers/stock at checkout | ❌ No stock concept exists to validate. |
|
||||
| Multi-seller cart grouped by seller and fulfillment rules | ❌ Undefined behaviour — already flagged in our own gaps doc. |
|
||||
| No duplicate payment intents on double-click | 🟡 Popup state guards the UI; nothing server-side. |
|
||||
|
||||
**Blocker:** [BACKEND-API-REFERENCE.md §7](../BACKEND-API-REFERENCE.md) states *"Payments are frozen — this call chain is explicitly out of scope for changes."* The plan's P0-A and P0-C cannot be delivered without unfreezing it. **This needs an explicit decision from whoever froze it.**
|
||||
|
||||
### 3.5 Payments (§2.7, §3.2)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Explicit state machines: `PaymentIntent` / `Payment` / `Order` | ❌ None. Payment status is a client-side signal with values `creating/waiting/success/timeout/error`. |
|
||||
| Webhook signature verification + idempotency | ❌ None. `webhook` appears only as a display field in the admin **monitoring mock**. |
|
||||
| Store `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` | ❌ None. |
|
||||
| "No artificial fixed delays" | ✅ **We already comply.** We poll real provider status (`/qr/dynamic/{partnerId}/{qrId}`, `/card/{partnerId}/{orderId}`) on an interval bounded by the QR TTL. There is no 5-second timer in this codebase. |
|
||||
| Refunds as a first-class operation with reason/actor/order-line link | ❌ `requestRefund(id)` exists only as a mock gateway method. |
|
||||
| Reconciliation queue | ❌ None. |
|
||||
|
||||
**Ask them:** §3.2 describes a fixed 5-second payment. We cannot reproduce it here. Either they observed a different build/environment, or they inferred it from the *admin* mock data. Worth pinning down before we spend P0 budget on a problem that may not be ours.
|
||||
|
||||
### 3.6 Orders and Fulfillment (§2.8, §3.6)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Canonical `Order` regardless of source (storefront / external marketplace / backoffice / API partner) | ❌ Admin Orders is a **static 24-row in-memory seed with no create path**, and no DI token to swap it. |
|
||||
| `OrderLine` with SKU/title/price snapshots | ❌ |
|
||||
| `Source mapping` (`externalMarketplace`, `externalOrderId`, `connectorId`) | ❌ |
|
||||
| `Fulfillment` (manual / warehouse / pickup / digital) with evidence | ❌ One `fulfil*` hit in the entire codebase. |
|
||||
| `Timeline` of all order events | ❌ Already logged as our own frontend-blocked TODO ("Real order audit trail"). |
|
||||
| Admin actions: assign, resend notification, replay sync, cancel/refund by permission | ❌ |
|
||||
| **No special branch for inspectors — any published, available product must be genuinely buyable and fulfillable** | ❌ We have no publish-time executability validation and no fulfillment flow, so we cannot currently *prove* compliance either way. |
|
||||
|
||||
### 3.7 Customer identity (§2.9, §3.4, §14)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| `Customer` + multiple `ExternalIdentity` + verified `ContactMethod` | ❌ Telegram user is effectively the customer identity. |
|
||||
| `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt` | ❌ |
|
||||
| Order contact snapshot, immutable after order creation | ❌ |
|
||||
| Email/phone OTP | 🟡 **Designed, not built** — spec approved 2026-08-15. |
|
||||
| VK ID / MAX | ❌ New in v3.1, nothing exists. |
|
||||
| Guest checkout toggled by tenant policy | ❌ |
|
||||
|
||||
### 3.8 Notifications (§2.10, §3.5)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Platform event bus emitting `order.created` / `order.paid` / `payment.failed` / `webhook.error` / `stock.low` / `oversell` / `refund.*` / `external_order.imported` | ❌ |
|
||||
| Notification with `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link** | 🟡 `AdminOrderWatcherService` polls for new orders and toasts/badges the admin — the right shape, wrong data source. |
|
||||
| Unread counter + filter by marketplace / event type in backoffice | 🟡 Partial (counter yes, marketplace filter no). |
|
||||
| External channel delivery status logged; a Telegram/email failure must not lose the internal notification | ❌ |
|
||||
|
||||
**Status:** the notification feature is built and **functionally inert** — it polls the mock Orders gateway, which has no create path, so no new order can ever appear. It starts working the day Orders gets a real backend, with no further frontend change.
|
||||
|
||||
### 3.9 Analytics (§3.1, §6.3)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Server-side event logging: `session_started`, `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `payment_started/success/failed`, `order_created` | ❌ **No tracking pipeline exists at all.** Not a missing endpoint — missing infrastructure. Our own docs rate it the single largest remaining backend effort. |
|
||||
| Operational metrics: notification latency, fulfillment time, connector lag, webhook lag | ❌ |
|
||||
| Quality metrics: frontend/backend errors, checkout validation failures, FX stale blocks | ❌ |
|
||||
| Real funnel in backoffice | ❌ Admin Analytics composes five mock gateways and has no data source. |
|
||||
| Synthetic traffic technically separated from production analytics | ⚠️ Cannot comply — there is no production analytics to separate it from. |
|
||||
| Product view counts | 🟡 A "Views" column was shipped in Admin Products; it always renders `0` because no tracking source exists. Storefront `Item.visits` is live-wired but displayed nowhere. |
|
||||
|
||||
### 3.10 Backoffice navigation (§4.1) — 12 required sections, 5 missing outright
|
||||
|
||||
Have (mock unless noted): Overview/Dashboard, Catalog (Categories real, Products mock), Orders, Payments partial (Transactions), Customers, Notifications partial, Content & Design (builder/CMS, `localStorage` only), Monitoring, Reports, Users, Settings.
|
||||
|
||||
Missing entirely:
|
||||
|
||||
- **Marketplaces** — registry, type, status, domains, currencies, feature set, responsible manager. Nothing.
|
||||
- **Sellers** — organizations, applications, roles, listings, integration health. Placeholder page only.
|
||||
- **Payments & Finance** — refunds, reconciliation, unmatched events, settlements. `settlement*` = 0 hits.
|
||||
- **Integrations** — external connectors, payment providers, FX sources, messaging. Nothing.
|
||||
- **Domains & Releases** — DNS/SSL, staging, production, health checks, rollback. Nothing.
|
||||
- **Audit & Security** — role changes, sensitive actions, login/security events, exports. `audit` appears only as display fields on mock models.
|
||||
|
||||
### 3.11 Roles and RBAC (§4.4, §10.1) — ⚠️ our most serious security gap
|
||||
|
||||
The plan specifies three scopes and 17 named roles (5 platform, 7 marketplace, 5 seller).
|
||||
|
||||
Our state: **the admin role model is decorative.** `AdminRole` and permissions exist as types, but nothing gates any button, page or action anywhere in the app. Anyone who passes admin authentication has full access. `AdminRole` is additionally defined twice with unrelated shapes.
|
||||
|
||||
Also missing from §10.1: idempotency keys, rate-limit handling (429 has zero client-side handling), step-up authentication for financial actions, audit log, PII minimisation policy.
|
||||
|
||||
### 3.12 External marketplace integrations (§5) — 0% built
|
||||
|
||||
Nothing in this section exists in any form: connector contract, webhook-preferred/polling-fallback ingestion, raw event storage, normalizer, SKU mapping, unmatched queue, exponential retry, dead-letter, manual replay, reconciliation, connector observability, and the proposed SLA (99% of webhook events processed under 60s, zero duplicate orders).
|
||||
|
||||
**Blocking unknown:** the plan never names which external marketplaces. Ozon? Wildberries? Yandex Market? Avito? Each is a separate connector with its own auth and rate limits. We cannot size this without the list.
|
||||
|
||||
### 3.13 Domains, publishing and tenant launch (§8)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Marketplace lifecycle `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, with the blocking item shown per transition | ❌ |
|
||||
| DNS automation via Hostinger API (7 endpoints listed), snapshot + rollback, never touching MX/SPF/DKIM/DMARC/CAA, approval gate in production, propagation + SSL + health checks | ❌ Zero references. |
|
||||
| Publish model: `draft → validation → preview → publish`, immutable published revision, rollback creates a new revision | 🟡 The builder edits an in-memory config and persists drafts to `localStorage`. "Publish" only promotes a local signal. No revisions, no server-side publish endpoint (`apiEndpoints.builder` is an empty placeholder). |
|
||||
| Commerce data explicitly **not** part of content revisions | ✅ Structurally true today — orders/payments simply aren't in the revision at all. |
|
||||
|
||||
### 3.14 API boundaries (§9.3) — ⚠️ a naming migration we have not planned
|
||||
|
||||
Plan namespaces: `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`.
|
||||
|
||||
Ours: unversioned, flat — `/cart`, `/orders`, `/items`, `/category`, `/searchitems`, plus a separate `qrApiUrl` host. Our own reference says **"No API versioning scheme has been decided"**.
|
||||
|
||||
Adopting the plan's namespaces is a coordinated frontend+backend rename, not a config change. It should be sequenced *before* the new commerce endpoints are built, not after.
|
||||
|
||||
Also in §9: the plan's error model assumes a structured envelope. Ours is a proposal only — no interceptor inspects error bodies today; every error reaction happens at raw HTTP-status level.
|
||||
|
||||
### 3.15 Gorbushka as a tenant (§11)
|
||||
|
||||
The plan lists mall-directory content entities: `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings` — each with `marketplaceId`, audit, and publish/preview.
|
||||
|
||||
We have: static pages inside the bootstrap document. None of the other nine entity types exist, and CMS content has no backend write path at all.
|
||||
|
||||
Positive read: the plan explicitly says Gorbushka must **not** dictate platform architecture, and that the existing frontend is UX reference only. That matches our ADR-0001 constraint ("frontend must not contain marketplace-specific code"). No conflict here — just unbuilt scope.
|
||||
|
||||
### 3.16 Definition of Done (§13) — where we stand today
|
||||
|
||||
Of the 13 launch-checklist items, we can currently claim **zero** as green. Additionally, our own QA position makes their DoD hard to evidence:
|
||||
|
||||
- ~32% statement coverage, ~19% branch coverage, 11 spec files repo-wide.
|
||||
- **Zero E2E tests** — no Playwright/Cypress config anywhere. The plan's acceptance criteria are all end-to-end by construction.
|
||||
- Several past "verified live" claims were code-inspection only, because `/edit` and `/backoffice` require Telegram admin login that automated environments cannot complete.
|
||||
|
||||
---
|
||||
|
||||
## 4. What we have that the plan does not account for
|
||||
|
||||
Not gaps — assets and risks they should know about before sequencing:
|
||||
|
||||
1. **Project editor / builder** (~1.0 MB lazy chunk) — a full visual site builder. The plan's §8.3 publish model would replace its persistence layer entirely.
|
||||
2. **Ed25519 challenge/response admin auth** — fully wired client-side, backend returns 404 today. The plan never mentions it; it assumes conventional RBAC.
|
||||
3. **Widget manifest / dynamic renderer** — the mechanism that makes one storefront runtime serve many tenants. This is the part of the plan we have *already* solved and should defend.
|
||||
4. **Super-admin Phase 1 design** (`docs/superpowers/specs/superuser.md`) — cross-tenant read-only view. Overlaps §4.3 Marketplace Registry and §10 audit. Worth re-scoping against the plan rather than building as specified.
|
||||
5. **Three in-flight items already answer v3.0 P0s:** admin purchase notifications (§3.5), admin product views column (§3.1), email/phone OTP login (§3.4). Two of the three are inert until a real backend exists.
|
||||
|
||||
---
|
||||
|
||||
## 5. Manager's read — the honest framing
|
||||
|
||||
**Split of ownership.** Roughly 80% of this document is backend and platform-service work: Platform API, Workers/Event Processing, Integration Hub, Domain Automation, payment state machines, reconciliation, analytics pipeline. This repository is a frontend. Of the plan's ~14 sections, only Storefront Runtime (§6.1) is substantially delivered, and it is delivered *well*.
|
||||
|
||||
**The real message is trust, not features.** Every P0 in §3 is a variant of "we cannot explain your numbers." Sequencing should follow that: traceability first (money model, price snapshot, payment timeline, audit), feature breadth second. That happens to also be the plan's own P0-A ordering.
|
||||
|
||||
**The largest single risk is not scope — it is the frozen payment chain.** Cart is client-owned, price is client-supplied, orders are fire-and-forget, and the whole chain is marked "do not modify." Three P0s sit behind that freeze. Nothing else in this list can be honestly estimated until that decision is reversed or explained.
|
||||
|
||||
**Second risk: RBAC.** The plan assumes 17 enforced roles across three scopes. We enforce none. Any real admin backend going live before this is fixed hands full platform access to every authenticated operator.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions — answered 2026-08-17
|
||||
|
||||
See [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record and downstream consequences. Summary:
|
||||
|
||||
1. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository owns the frontend and the contract set.
|
||||
2. **Payment chain — unfrozen. Yes.** Phases 1, 6, 7 proceed.
|
||||
3. **External marketplaces — no fixed list.** Connectors onboard partners as they arrive; build the Phase 4 framework config-driven/generic, not per-named-provider.
|
||||
4. **FX rate source — ours, in-house, as a safety gate.** No external provider committed; backend computes FX authoritatively until/unless one is chosen later.
|
||||
5. **§14 vs. OTP — VK ID first, then everything else** ("do all after vk"). Phase 8 resequenced.
|
||||
6. **Multi-seller orders — unified.** One `Order` per checkout, seller-scoped `Fulfillment` groups internally. Resolves the three-document disagreement.
|
||||
7. **"Fixed 5-second payment" — resolved as a non-issue.** `PAYMENT_POLL_INTERVAL_MS` is already `5000` — that's poll cadence against real provider status, not an artificial delay. Confirmed compliant, no change needed.
|
||||
8. **API namespace — new endpoints only, no forced migration.** `/api/v2/...` used for all new Phase 1+ contracts; legacy endpoints stay as-is pending a dedicated migration sprint.
|
||||
9. **Document version — v3.1 is canonical.** The source PDF's internal "3.0" version block is stale.
|
||||
|
||||
---
|
||||
|
||||
## 7. Suggested first slice (if they want a proposal back)
|
||||
|
||||
Following their own dependency order, restricted to what is buildable and provable:
|
||||
|
||||
1. **Money model + FX quote + price snapshot** (P0-A) — needs the payment freeze lifted. Removes client-supplied `amount`, kills the float math, gives every total an explainable formula. This one item closes §3.3, §3.8 and half of §13.1.
|
||||
2. **Order canonical model + timeline + notification wiring** (P0-B) — the notification feature already exists and switches on for free.
|
||||
3. **RBAC enforcement** — not on their P0 list, but it is the gate on everything else in the backoffice going live safely.
|
||||
4. **Analytics event pipeline** (P0/§3.1) — long lead time, so start it in parallel rather than last.
|
||||
|
||||
Explicitly *not* in a first slice: Seller Portal, external connectors, domain automation, VK/MAX bots. All of them depend on the commerce core being real first, which is what the plan itself says in §12.1.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Product Backlog
|
||||
|
||||
Items that need a client/business decision before any code is written — not blockers, not bugs, not backend work. Verified against current repo state 2026-07-26.
|
||||
|
||||
## Dark mode / Theme selector
|
||||
|
||||
`theme-section`'s light/dark/system dropdown saves correctly and `theme-engine.service.ts` sets a `data-theme-mode` attribute on `<html>`, but no CSS anywhere in the app reads that attribute — picking Dark or System changes nothing visually today. Theme palette colors themselves are unaffected (real CSS custom properties, genuinely live).
|
||||
|
||||
**Decision needed:** does the client want a real dark mode? If yes, this is a real feature project (dark palette + `[data-theme-mode]`/`prefers-color-scheme` strategy + a `matchMedia` listener for "system"), not a wiring fix.
|
||||
|
||||
## Brand color contrast (WCAG AA)
|
||||
|
||||
`--border-color` fails 3:1 UI-component contrast in every theme (1.24–1.42:1 measured); `--success`/`--warning`/`--error`/`--info-color` fail 4.5:1 when used as plain text-on-white in a handful of places. These are real palette colors, not a token bug — fixing means visibly changing the brand.
|
||||
|
||||
**Decision needed:** theme-owner sign-off on adjusted brand colors before any change ships.
|
||||
|
||||
## Design-token gap: `stars.component` rating glyph color
|
||||
|
||||
`src/app/features/website/product/engagement/components/stars/stars.component.scss:10` uses a literal hex (`#cdd6d5`) with no matching design token.
|
||||
|
||||
**Decision needed:** add a token for this exact shade, or intentionally reuse an existing token (visual shift either way) — needs a design-system owner's call, not an engineering guess.
|
||||
|
||||
## Footer "Contacts" page content
|
||||
|
||||
The footer's "Contacts" link (`footer-contacts` / `nav.contacts`) has no static-page content in the bootstrap mock data at all — unlike "About" (which was a route-name mismatch, already fixed), there's simply nothing written for Contacts.
|
||||
|
||||
**Decision needed:** what should the Contacts page actually say (address, phone, hours, map?) — a content question, not a code fix.
|
||||
|
||||
## Advanced analytics (traffic, funnels, heatmaps)
|
||||
|
||||
No data source exists for site traffic, conversion funnels, or heatmaps anywhere in the frontend or backend plan — this is a from-scratch analytics pipeline, not a missing endpoint.
|
||||
|
||||
**Decision needed:** does the client want this for launch or later, and which analytics vendor/build to use (build vs. buy).
|
||||
|
||||
## Payment providers
|
||||
|
||||
Current checkout supports QR and card via the existing custom payment flow (`bank-payment-modal`, `payViaCard`). No alternative payment providers are wired or planned.
|
||||
|
||||
**Decision needed:** if additional payment providers (e.g. wallets, buy-now-pay-later) are wanted, needs a business decision on which providers before any integration work starts.
|
||||
@@ -1,60 +0,0 @@
|
||||
# PROJECT STRUCTURE
|
||||
|
||||
Folder-by-folder tour of `src/app/**`, then one worked example (the Sprint 19 admin dashboard) followed as a literal file-by-file walk-through, ending with a checklist for adding your own feature.
|
||||
|
||||
Standards referenced below are enforced, not suggestions: `docs/architecture/foundation/Folder-Blueprint.md`, `Naming-Conventions.md`, `Dependency-Rules.md`, `Import-Boundary-Matrix.md`.
|
||||
|
||||
## Top-level folders
|
||||
|
||||
| Folder | What belongs here | Why |
|
||||
|---|---|---|
|
||||
| `core/` | Per-domain: DTOs, mappers, domain models, repositories, domain services (e.g. `core/categories/`, `core/products/`, `core/search/`, `core/admin-auth/`). | Isolates backend-shaped data (DTOs) from the rest of the app. Only the mapper inside a domain's `core/<domain>/` folder is allowed to see both DTO and domain model shapes (ADR-003 import boundaries). |
|
||||
| `facades/` | Cross-feature facades not owned by a single feature, e.g. `facades/platform/category.facade.ts`, `facades/platform/search.facade.ts`. | The only thing components are allowed to inject for data/state (ADR-006/007). Feature-local facades instead live inside that feature's own `facade/` folder (see `features/project-editor/facade/`, `features/admin/dashboard/facade/`). |
|
||||
| `features/` | One folder per feature/domain: `project-editor/`, `admin/<subfeature>/`, `backoffice/<subfeature>/`, `website/catalog/`, `website/product/`, `search/`, `content-management/`, `diagnostics/`. | Organized by feature, not by file type — a feature's models/services/facade/components/pages all live together (`docs/architecture/foundation/Folder-Blueprint.md`). |
|
||||
| `shared/` | `shared/models/config/*` (the `BootstrapConfig` and ~20 sub-configs), reusable presentational UI, utils. | Feature-agnostic by contract — `shared/` must never import from `features/` (Import-Boundary-Matrix). |
|
||||
| `widgets/` | `contracts/` (widget manifest contract), `registry/` (manifest service), `resolvers/` (data-source resolver), `ui/` (widget components). | The dynamic rendering engine — see `docs/ARCHITECTURE.md`. |
|
||||
| `dynamic-renderer/` | `section-engine/`, `page-renderer/`, `section-renderer/`, `widget-host/`. | The page-composition pipeline that turns bootstrap JSON into rendered pages. |
|
||||
| `layouts/` | Page-chrome containers, e.g. `layouts/containers/dynamic-page-layout.component.ts`. | Top-level layout composition, one level above pages. |
|
||||
| `i18n/` | `translations.ts` (interface), `en.ts`/`ru.ts`/`hy.ts`, `translate.pipe.ts`, `TranslateService`. | Single source of truth for all user-facing copy — see `docs/FRONTEND.md`. |
|
||||
| `pages/` | Top-level routed pages not part of a larger feature module (`home`, `cart`, `category`). | Simpler routed pages that don't warrant a full `features/` module. |
|
||||
| `components/` | Reusable standalone components shared across features/pages (`product-card`, `telegram-login`). | Presentational, input/output-only (ADR-006) — no facade/HttpClient/storage access. |
|
||||
| `guards/` | Route guards. | Kept separate from `core/admin-auth/` because `admin-auth.guard.ts` is domain-specific; generic guards live here. |
|
||||
|
||||
## Worked example, end to end: the Sprint 19 admin dashboard
|
||||
|
||||
`src/app/features/admin/dashboard/` — read in the order a new engineer would build it.
|
||||
|
||||
1. **Model** — `models/admin-dashboard.model.ts`. Plain interfaces/types for card data, card status (`loading|empty|error|pending-backend|ready`), health-check entries. No behavior, no imports from Angular DI.
|
||||
|
||||
2. **Gateway interface** — `services/admin-dashboard-metrics.gateway.interface.ts`. An abstract contract (`AdminDashboardMetricsGateway`) for "however we get category/product counts" — deliberately decoupled from *how* (local computation vs. real API) so the facade never knows which implementation is active.
|
||||
|
||||
3. **Gateway implementation** — `services/admin-dashboard-metrics.local.gateway.ts`. `AdminDashboardMetricsLocalGateway implements AdminDashboardMetricsGateway`, composing `BackofficeDataService.loadCategories()/loadProducts()` (already used elsewhere) into counts. A future `AdminDashboardMetricsApiGateway` would implement the same interface against a real endpoint (see `docs/BACKEND.md` §3 CRUD Contracts / §8 migration guide) — nothing above this layer changes when that happens.
|
||||
|
||||
4. **DI token** — `services/admin-dashboard-metrics-gateway.token.ts`. `const ADMIN_DASHBOARD_METRICS_GATEWAY = new InjectionToken<AdminDashboardMetricsGateway>(...)`, bound to the local gateway by default in `app.config.ts`. This is the swap point: rebinding this token to a real API gateway is the *only* change needed to go from mock to real data.
|
||||
|
||||
5. **Supporting service** — `services/admin-dashboard-history.service.ts`. `localStorage`-backed activity log, scoped per tenant — a second, narrower concern (recent activity) that doesn't belong in the metrics gateway.
|
||||
|
||||
6. **Facade** — `facade/admin-dashboard.facade.ts`. `AdminDashboardFacade` is the *only* thing the components below are allowed to inject. It composes `ProjectEditorFacade` (existing — bootstrap/status/validation), `ADMIN_DASHBOARD_METRICS_GATEWAY` (via the token, not the concrete class), and `AdminDashboardHistoryService`, and exposes computed signals per card (status + value) plus the health-check list and quick-actions list.
|
||||
|
||||
7. **Presentational components** — `components/admin-dashboard-card.component.*`, `admin-dashboard-quick-actions.component.*`, `admin-dashboard-activity.component.*`, `admin-dashboard-health.component.*`. Each takes only `@Input()`s (card data, health entries, quick-action list) — no `HttpClient`, no `localStorage`, no route access, no facade injection. This is what makes them independently testable and reusable.
|
||||
|
||||
8. **Page container** — `pages/admin-dashboard-page.component.*`. Injects `AdminDashboardFacade`, computes per-card status from bootstrap-loaded/metrics-error/empty conditions, prefixes `routerLink`s with the current locale (`LanguageService.currentLanguage()`), and passes plain data down to the presentational components above. This is the only place in the feature that knows about routing or the facade.
|
||||
|
||||
9. **Route wiring** — `app.routes.ts`. `/:lang/backoffice/dashboard -> AdminDashboardPageComponent`, guarded by `adminAuthGuard`; `/:lang/backoffice` (empty path) redirects to `dashboard`.
|
||||
|
||||
Full narrative and known gaps: `docs/archive/ADMIN.md` (historical build log) and `docs/BACKEND.md` (current contract).
|
||||
|
||||
## Steps to add a new feature (derived from the example above)
|
||||
|
||||
1. Decide: does this belong in `features/<area>/<feature>/`, or is it simple enough for `pages/`? Route-guarded, multi-component admin/backoffice work goes in `features/admin/*` or `features/backoffice/*`.
|
||||
2. Define the domain model(s) first (`models/*.model.ts`) — no behavior, no DI.
|
||||
3. If the feature needs data that might later come from a real backend, define a gateway/repository **interface** before writing any implementation.
|
||||
4. Implement a local/mock gateway against existing data sources where possible (reuse, don't duplicate — check `core/*` and other features' services first).
|
||||
5. Create an `InjectionToken` for the gateway and bind it to the local implementation in `app.config.ts` (or the relevant provider scope). This is the seam a backend integration will use later — never inject the concrete class directly from a facade or component.
|
||||
6. Write the facade. It is the only consumer of the gateway token, and the only thing components inject.
|
||||
7. Build presentational components as `@Input()`/`@Output()`-only — verify none of them import `HttpClient`, storage, or a facade.
|
||||
8. Build the container/page component that injects the facade and wires routing.
|
||||
9. Add routes in `app.routes.ts`, with `adminAuthGuard` (or the relevant guard) if it's an admin surface.
|
||||
10. Add every new user-facing string to `i18n/translations.ts` (interface) then `en.ts`/`ru.ts`/`hy.ts` — never hardcode copy in a template.
|
||||
11. Document backend gaps (if any) in `docs/BACKEND.md` §3 (CRUD Contracts, endpoints by domain), marking proposed/unimplemented endpoints as such.
|
||||
12. Run `npm run arch:check` (import boundaries + circular dependencies) and `npx tsc -p tsconfig.app.json --noEmit` before committing.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Marketplace Platform — Documentation Index
|
||||
|
||||
This is the entry point. Read this first — it links to everything else and tells you what's actually true right now versus what's historical.
|
||||
|
||||
## What this is
|
||||
|
||||
A configuration-driven, multi-tenant SaaS marketplace platform (Angular 21.1, standalone components). One frontend codebase serves unlimited tenants ("marketplaces"). Tenant identity, theme, navigation, page/section/widget composition, and static content all resolve from a per-tenant `bootstrap.json` fetched at runtime — no tenant-specific code paths exist in the frontend. New tenants are onboarded by domain + config + backend data, never by forking the frontend.
|
||||
|
||||
Every tenant has three surfaces on this one codebase:
|
||||
- **Website** — the public storefront (catalog, product pages, cart, static pages).
|
||||
- **Builder** (Project Editor, `/edit/**`) — an in-app editor that edits the tenant's `BootstrapConfig`.
|
||||
- **Backoffice** (Admin, `/:lang/backoffice/**`) — the admin area: products, categories (live-wired to a real gateway), orders, transactions, users, monitoring, analytics, media.
|
||||
|
||||
## System overview
|
||||
|
||||
- **Architecture**: `Component (container) → Facade → Domain Service → Repository/Provider (DI token, swappable mock↔API) → Mock | API`. Enforced by `npm run arch:check` (import boundaries + circular deps), not just convention. Full detail: [ARCHITECTURE.md](ARCHITECTURE.md), governance docs at `docs/architecture/foundation/**` (11 ADRs + 9 standards docs).
|
||||
- **Seller Management** (optional, not built): typed foundation + Phase 1 Backoffice placeholder UI only — `modules.sellerManagement.enabled` gate on `BootstrapConfig`, disabled by default, zero effect on existing marketplaces. Full capability doc: `docs/architecture/foundation/Seller-Management.md`.
|
||||
- **State**: Signals-based facades everywhere, no NgRx (ADR-007).
|
||||
- **Rendering**: Bootstrap JSON → Section Engine → Page Renderer → Widget Host → registered widget component (ADR-005). 100% lazy-loaded routes.
|
||||
- **Theming**: CSS custom properties per tenant, 3 theme stylesheets, never hardcoded hex in a component (ADR-008). Design system spec: [`DESIGN.md`](../DESIGN.md) (root of repo).
|
||||
- **i18n**: 3 locales (en/ru/hy), compile-time-enforced key parity across locale files.
|
||||
- **Backend**: mostly PLANNED (mock gateways behind swappable provider tokens) — see [BACKEND.md](BACKEND.md), the single canonical backend spec (architecture, bootstrap, auth, JWT, Ed25519, permissions, maintenance mode, error model, every endpoint, DTOs, uploads, pagination/filters/sorting, publish workflow, media, builder, implementation checklist). Categories is the one domain fully wired to a real HTTP gateway; everything else is local/mock.
|
||||
|
||||
## Doc index (living documents)
|
||||
|
||||
Read these directly — they're the current source of truth, not one-off reports:
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Layered architecture, container/facade/service pattern, bootstrap/theme/widget engines, links to the enforced ADRs |
|
||||
| [BACKEND.md](BACKEND.md) | **The one canonical backend spec** — auth, JWT, Ed25519, permissions, maintenance mode, every endpoint, DTOs, uploads, error model, migration guide, checklist |
|
||||
| [FRONTEND.md](FRONTEND.md) | App structure, routing, i18n, theming, state management, dynamic rendering |
|
||||
| [EDITOR.md](EDITOR.md) | The Project Editor: every section, save/publish/draft/reset model |
|
||||
| [StaticPages.md](StaticPages.md) | The Static Pages CMS module (the thing that actually serves About/Contacts/etc. today) |
|
||||
| [PROJECT-STRUCTURE.md](PROJECT-STRUCTURE.md) | Folder-by-folder tour of `src/app/**` with a worked feature-add example |
|
||||
| [PROJECT_STATUS.md](PROJECT_STATUS.md) | **Current status** — completion %, readiness for demo/production/backend, honest limitations |
|
||||
| [NEXT_PHASE.md](NEXT_PHASE.md) | The one roadmap — backend integration → testing → performance → monitoring → v2 ideas |
|
||||
| [TODO.md](TODO.md) | Release blockers only — currently empty |
|
||||
| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | Real, reproducible, currently-open frontend bugs only |
|
||||
| [PRODUCT_BACKLOG.md](PRODUCT_BACKLOG.md) | Items needing a client/business decision (dark mode, brand colors, page content, etc.) |
|
||||
| [FUTURE_FEATURES.md](FUTURE_FEATURES.md) | Nice-to-have, non-blocking future work (Angular 22, bundle splitting, etc.) |
|
||||
| [ANGULAR22_PLAN.md](ANGULAR22_PLAN.md) | Angular 22 upgrade feasibility (research only, not yet executed — tracked in FUTURE_FEATURES.md) |
|
||||
| [SALES-GUIDE.md](SALES-GUIDE.md) | Plain-language guide for the sales team — what to demo, what's not live yet |
|
||||
| [`../DESIGN.md`](../DESIGN.md) | Visual design system: colors, typography, elevation, component specs |
|
||||
| [`../PRODUCT.md`](../PRODUCT.md) | Product positioning, users, brand personality, anti-references |
|
||||
| [`../CHANGELOG.md`](../CHANGELOG.md) | Keep-a-Changelog-format history of shipped features |
|
||||
| `docs/architecture/foundation/**` | Enforced ADRs (ADR-001…ADR-011) and standards docs — governance, read directly |
|
||||
| `docs/context/**` | Barry Cache's own source-backed memory system — infrastructure, not project documentation, do not edit by hand |
|
||||
| `docs/archive/**` | Superseded docs, kept for history only — do not implement against these |
|
||||
|
||||
**One topic, one place**: routing lives in FRONTEND.md, not repeated here. Backend contract lives entirely in BACKEND.md — nowhere else. Design tokens live in DESIGN.md, not repeated elsewhere.
|
||||
|
||||
## What's still open
|
||||
|
||||
[TODO.md](TODO.md) — release blockers only. [PRODUCT_BACKLOG.md](PRODUCT_BACKLOG.md) and [FUTURE_FEATURES.md](FUTURE_FEATURES.md) hold everything else that isn't a blocker.
|
||||
|
||||
## Historical reports
|
||||
|
||||
19 one-off audit/sprint/review reports were archived, then deleted once every open finding worth keeping was confirmed merged into [KNOWN-ISSUES.md](KNOWN-ISSUES.md)/[TODO.md](TODO.md). A 20th (`FRONTEND-ROADMAP.md`, despite its name a shipped-history changelog, not a forward roadmap) was archived to `docs/archive/` on 2026-07-26 for the same reason. Full original text recoverable via `git log --diff-filter=D -- docs/archive` or `docs/archive/FRONTEND-ROADMAP.md` itself.
|
||||
|
||||
## How to run it
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run start # ng serve
|
||||
npm run start:dexar # ng serve --configuration=development --port 4200
|
||||
npm run build # ng build
|
||||
npm run build:dexar # ng build --configuration=production
|
||||
npm run arch:check # boundary + circular-dependency checks
|
||||
```
|
||||
|
||||
Barry Cache (repo memory, optional but recommended before/after non-trivial work):
|
||||
|
||||
```bash
|
||||
npm run barry -- resume --task "<task>"
|
||||
npm run barry -- validate
|
||||
```
|
||||
|
||||
See root `CLAUDE.md` for the full Barry Cache workflow and memory policy.
|
||||
|
||||
## Current status
|
||||
|
||||
Full detail (completion %, per-area readiness, known limitations): [PROJECT_STATUS.md](PROJECT_STATUS.md). Short version:
|
||||
|
||||
- **Frontend**: Release Candidate, feature-complete. `TODO.md` has no blockers.
|
||||
- **Backend**: not implemented, fully specified. Categories is the one domain wired to a real gateway; everything else is mock. See [BACKEND.md](BACKEND.md).
|
||||
- **Documentation**: consolidated (Final Documentation Consolidation pass, 2026-07-26) — one canonical backend doc, one roadmap, one status doc, historical/sprint docs moved to `docs/archive/`.
|
||||
- **First client demo**: ready, with one caveat — admin role enforcement doesn't exist yet, see `PROJECT_STATUS.md`.
|
||||
|
||||
Draft/publish for the Project Editor is still **frontend-only** (localStorage), no backend persistence — the single largest backend gap, see [BACKEND.md §1 (Bootstrap: Draft vs Published)](BACKEND.md#1-bootstrap) and §8 (Real Backend Implementation Guide).
|
||||
@@ -1,44 +0,0 @@
|
||||
# Project Status
|
||||
|
||||
Date: 2026-07-26. Branch: `B2B`. Honest snapshot, verified against source — not aspirational.
|
||||
|
||||
## Completion estimates
|
||||
|
||||
Frontend-engineering estimates only (not effort/story-point estimates) — how much of the intended surface is built and working against mock data.
|
||||
|
||||
| Area | Completion | Basis |
|
||||
|---|---|---|
|
||||
| **Frontend (overall)** | **~95%** | `TODO.md` has zero release blockers; one known minor bug open (`KNOWN-ISSUES.md`); several items deliberately deferred as product decisions, not gaps. |
|
||||
| **Backend** | **~10%** | Only Categories has a real HTTP implementation. Every other domain is a working mock. The *specification* is 100% done (`BACKEND.md`); the *implementation* is not started. |
|
||||
| **UI (visual/component layer)** | **~95%** | No native browser dialogs, no known broken-image paths, no raw dev jargon in default admin views, no apology-toned empty states, consistent shared primitives across all three surfaces. |
|
||||
| **Admin (backoffice)** | **~85%** | UI built and working for every domain (dashboard, products, categories, orders, customers, transactions, users, moderation, media, monitoring, analytics) against mock data. Missing: role enforcement (model exists, nothing checks it), real data everywhere except Categories. |
|
||||
| **Storefront** | **~95%** | Feature-complete for the audited surfaces (home, catalog, product detail, cart, checkout UI, wishlist/compare, search, static/CMS pages). i18n complete (en/ru/hy near-parity). Runs against mock data. |
|
||||
|
||||
## Ready for first customer?
|
||||
|
||||
**Yes, for a demo. No, for production.** The storefront and builder demo end-to-end with no visible rough edges. Production readiness is blocked entirely on the backend not existing yet — see `BACKEND.md`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- One real frontend bug open: Ed25519 admin-auth error codes `session-expired`/`invalid-signature` are currently unreachable (see `KNOWN-ISSUES.md`).
|
||||
- Admin role model exists in code but isn't enforced by any route guard or UI gate — anyone who passes admin auth has full access regardless of assigned role.
|
||||
- No automated test suite exists for the components touched across recent RC passes (none existed before either).
|
||||
- Two large lazy chunks (`project-editor` 320 kB, `catalog-container` 126 kB) — not release-blocking (`FUTURE_FEATURES.md`).
|
||||
- 53 local `B2B` commits not yet pushed to `origin` (verified 2026-07-26) — pending explicit go-ahead, a process step not a code blocker.
|
||||
- Several product-decision items (dark mode, brand-color contrast, Contacts page content, advanced analytics, additional payment providers) documented but not scheduled — `PRODUCT_BACKLOG.md`.
|
||||
|
||||
## Backend waiting items
|
||||
|
||||
Everything in `BACKEND.md` §9 (Backend Checklist) — 34 items across 6 phases, from foundation (auth, tenant resolution, bootstrap, error envelope) through hardening (rate limiting, CSP, audit logging, maintenance mode). The single largest gap: the Project Editor (builder) has **no save/publish HTTP call at all** today — drafts live in-memory and in `localStorage` only.
|
||||
|
||||
## Authentication status
|
||||
|
||||
**Storefront: live.** Telegram/QR session login is the only way customers authenticate today, and it works end-to-end. **Admin: dormant.** Ed25519 challenge/response admin auth is fully built client-side (keypair service, signing flow, guard, interceptor) but the interceptor isn't registered in `app.config.ts` and the guard isn't attached to any route — it doesn't run in production today. No token refresh exists for either flow. Full contract: `BACKEND.md` §4.
|
||||
|
||||
## Builder status
|
||||
|
||||
Fully functional editor of in-memory/`localStorage` draft state (homepage sections, widgets, languages, navigation, footer, branding, theme, static pages). "Publish" today only promotes the local draft signal — nothing reaches a backend.
|
||||
|
||||
## Documentation status
|
||||
|
||||
Consolidated in this closeout pass. One canonical backend doc (`BACKEND.md`, merges everything that used to be five overlapping files). One roadmap (`NEXT_PHASE.md`). One status doc (this file). Historical sprint/audit reports live in `docs/archive/`, not in root `docs/`. `PROJECT_INDEX.md` is the entry point and every remaining doc is reachable from it. Not fully swept: a handful of low-traffic architecture docs (`docs/architecture/foundation/adr/**`, `FRONTEND.md`, `EDITOR.md`, `ARCHITECTURE.md`, `PROJECT-STRUCTURE.md`, `StaticPages.md`) still contain a few old filename references from before this consolidation — historical-context docs, not the navigation entry point, left as a known gap rather than swept blindly.
|
||||
@@ -1,123 +0,0 @@
|
||||
# Release Candidate RC-02 — Final Release Report
|
||||
|
||||
Date: 2026-07-26
|
||||
Branch: `B2B`
|
||||
|
||||
## Completed
|
||||
|
||||
1. **Storefront localization.** Replaced remaining hardcoded English strings
|
||||
(rating/discount aria-labels, hero-carousel dots, product-carousel prev/next
|
||||
buttons, dialog close button, toast dismiss, QR-code alt text, bank-payment
|
||||
iframe title, guest checkout fallback name) with `translate` pipe/service
|
||||
calls, backed by new `common.*` i18n keys in en/ru/hy.
|
||||
Commit: `1163bfd`.
|
||||
|
||||
2. **Empty-store wording.** Audited every empty-collection branch across
|
||||
storefront, builder, and backoffice. Found and fixed one real defect: the
|
||||
category/subcategory empty states used "Oops!"/"Упс!" apology framing for a
|
||||
normal zero-results condition. Everywhere else in the codebase already
|
||||
correctly separates a real `error()` branch from an empty-collection
|
||||
branch with distinct, neutral wording (verified across catalog, product,
|
||||
cart, wishlist/compare, admin list pages, dashboard, media, builder).
|
||||
Commit: `1163bfd`.
|
||||
|
||||
3. **Merchant-friendly wording (Monitoring/Analytics/Reports/Diagnostics).**
|
||||
Analytics, Reports (moderation/reports), and Diagnostics were already
|
||||
clean — no raw HTTP/queue-worker strings found. Monitoring had three
|
||||
developer-facing spots: background queue slugs, webhook event keys, and
|
||||
the activity log's "api" category showing a raw
|
||||
`GET /api/products responded 200 in 84ms` line as the primary message.
|
||||
All three now show plain-language labels by default, with the raw string
|
||||
for API/error/warning events moved behind a collapsed "Technical details"
|
||||
`<details>`. Commit: `ca343c4`.
|
||||
|
||||
4. **Dialog consistency.** Replaced all 12 native `confirm()` calls and 4
|
||||
native `alert()` calls across cart, media library, static-pages editor,
|
||||
and 5 builder components. Confirms now use a new shared
|
||||
`app-confirm-dialog` (composes the existing `app-dialog` + `app-button` —
|
||||
no new dependency), following the same local-signal pattern already used
|
||||
in admin-categories. Cart's alerts route through the existing
|
||||
`UserNotificationService` toast pipeline instead. Zero native
|
||||
`confirm`/`alert`/`prompt` remain in production code (verified by grep).
|
||||
Commit: `6c6fa00`.
|
||||
|
||||
5. **Images.** Found and fixed a real defect: `getMainImage()`'s no-photo
|
||||
fallback pointed at `/assets/images/placeholder.svg`, but that file (and
|
||||
the whole `assets/images/` directory) never existed — any item with zero
|
||||
photos rendered a browser broken-image icon. Added the asset. Also added
|
||||
an `(error)` handler on every dynamic `<img>` that renders a
|
||||
user/admin-supplied URL (product card, cart line item, cart payment QR,
|
||||
product gallery main + thumbnails), so a 404'd image URL swaps to the
|
||||
placeholder instead of shipping broken. Commit: `3e54e88`.
|
||||
|
||||
6. **Legacy cleanup.** Investigated `pages/category`, `pages/search`,
|
||||
`pages/item-detail`, `pages/info/**`, `pages/legal/**` (40+ files) and
|
||||
`dynamic-renderer/`. First five were confirmed unrouted dead code (each
|
||||
had a live replacement already serving its traffic) — deleted outright.
|
||||
`dynamic-renderer/` was confirmed **active** (it's the live homepage
|
||||
rendering pipeline via `HomeComponent` → `WebsiteRuntimeFacade` →
|
||||
`PageRendererService`/`PageResolverService` →
|
||||
`DynamicPageLayoutComponent`) — a prior doc note calling it "unwired" was
|
||||
stale and has been corrected. `docs/TODO.md`, `docs/KNOWN-ISSUES.md`,
|
||||
`docs/FRONTEND-ROADMAP.md`, `docs/PROJECT_INDEX.md` updated accordingly.
|
||||
Commit: `a670ca9`.
|
||||
|
||||
7. **Final QA.** `npx tsc --noEmit` clean after every commit above.
|
||||
`ng serve` production-mode build compiles with no errors. Manually
|
||||
smoke-tested in-browser: home page loads with zero console errors; cart
|
||||
page loads with mock data; the new clear-cart confirm dialog opens with
|
||||
correctly translated title/message/buttons, Cancel closes it without
|
||||
side effects, zero console errors throughout. Backoffice route requires
|
||||
an authenticated admin session (existing `adminAuthGuard` behavior,
|
||||
unrelated to this pass) so the Monitoring page's new wording was verified
|
||||
by reading the compiled template/component, not by an authenticated
|
||||
click-through.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The i18n string audit and empty-state audit were scoped to storefront/
|
||||
customer-facing surfaces per the task list; backoffice/builder templates
|
||||
were spot-checked but not exhaustively re-audited for hardcoded strings.
|
||||
- `app-confirm-dialog` is a new small shared component (composes existing
|
||||
`app-dialog`/`app-button`, no new library). It intentionally does not
|
||||
cover every dialog in the codebase — only the sites that were previously
|
||||
using native `confirm()`/`alert()`.
|
||||
- Backoffice Monitoring's Technical-details fix only touches the mock local
|
||||
gateway (`AdminMonitoringLocalGateway`); once a real API-backed gateway
|
||||
exists, it will need to populate `technicalDetail` the same way to keep
|
||||
the "Technical details" affordance working.
|
||||
- No new automated tests were added for this pass (none existed for the
|
||||
touched components beforehand either); verification was typecheck +
|
||||
manual smoke test as described above.
|
||||
|
||||
## Deferred items
|
||||
|
||||
- Everything already tracked in `docs/TODO.md` under "Backend — skipped,
|
||||
doing together" remains deferred (bootstrap real content, builder
|
||||
publish/validate backend, backoffice CRUD, media pipeline) — explicitly
|
||||
out of scope per this task's "NO BACKEND CHANGES" instruction.
|
||||
- Non-blocking pre-existing items from prior RC passes noted in
|
||||
`docs/KNOWN-ISSUES.md` (genuine brand-color contrast failures needing
|
||||
theme-owner sign-off, 2 large lazy chunks needing a dedicated split task,
|
||||
`primeng`/`primeicons` removal blocked on an unrelated `barry-cache`
|
||||
dependency issue) are unchanged by this pass.
|
||||
|
||||
## Launch recommendation
|
||||
|
||||
**Ready to ship** from a customer-demo-polish standpoint: no native browser
|
||||
dialogs, no broken-image paths on the audited surfaces, no raw developer
|
||||
jargon in Monitoring's default view, no apology-toned empty states, and the
|
||||
five dead-code page directories are gone rather than lingering as
|
||||
demo-confusing zombies. The remaining known limitations above are scope
|
||||
boundaries (backend, exhaustive re-audit, test coverage) rather than found
|
||||
defects — recommend proceeding, with the backoffice-auth-gated smoke test
|
||||
as the one item worth a human doing a real authenticated click-through on
|
||||
before the actual demo.
|
||||
|
||||
## Commits (this pass)
|
||||
|
||||
- `1163bfd` fix(storefront): replace hardcoded strings with i18n, neutral empty-state wording
|
||||
- `a670ca9` chore(cleanup): delete unrouted legacy pages, update docs
|
||||
- `6c6fa00` fix(ui): replace native confirm()/alert() with shared dialogs and toasts
|
||||
- `3e54e88` fix(storefront): add missing placeholder image asset and onerror fallback
|
||||
- `ca343c4` fix(backoffice): merchant-friendly wording in Monitoring
|
||||
@@ -1,79 +0,0 @@
|
||||
# Sales Guide — How to Use & Demo the Marketplace Platform
|
||||
|
||||
Audience: sales team. Plain-language guide to what the product does and how to show it. No code. When something isn't live yet, it's marked **Coming soon** so you never over-promise in a demo.
|
||||
|
||||
## What we're selling in one sentence
|
||||
|
||||
A **multi-tenant marketplace platform**: one codebase runs many branded marketplaces, and each customer gets their own storefront + a self-service admin panel to run it — no developer needed for day-to-day changes.
|
||||
|
||||
## The two halves of the product
|
||||
|
||||
1. **The storefront** — what shoppers see: homepage, catalog, product pages, search, cart, wishlist, compare, multi-language, multi-currency.
|
||||
2. **The admin / editor** — what the marketplace owner uses to run and customize it, without touching code.
|
||||
|
||||
## The headline demo: "change your whole store without a developer"
|
||||
|
||||
This is the strongest pitch. Open the **Project Editor** and show that a marketplace owner can restyle and reconfigure the entire storefront themselves. It has 11 tabs:
|
||||
|
||||
| Tab | What you show the prospect |
|
||||
|---|---|
|
||||
| General | Set the marketplace name, domain, description, and languages |
|
||||
| Branding | Upload logo, small logo, favicon |
|
||||
| Theme | Pick brand colors with a color picker, light/dark mode, choose a site layout |
|
||||
| Header | Toggle which features appear in the top bar (search, cart, wishlist, languages…) |
|
||||
| Footer | Company info, address, contacts, payment icons, social links |
|
||||
| Homepage | Drag-and-drop the order of homepage sections, choose layouts |
|
||||
| Widgets | Configure homepage blocks (hero banner, category grid, product rows) |
|
||||
| Marketplace Features | Turn features on/off (reviews, recommendations, recently-viewed, search history…) |
|
||||
| Languages | Add or remove a language for the whole store |
|
||||
| Navigation | Edit the menu links, per language |
|
||||
| Static Pages | Write pages like "About Us" with a rich text editor |
|
||||
|
||||
**Demo flow that lands well:**
|
||||
1. Change the brand color in **Theme** → show the store instantly reflecting it in **Preview**.
|
||||
2. Reorder homepage sections in **Homepage** by dragging.
|
||||
3. Edit an "About Us" page in **Static Pages** using the text editor (bold, headings, lists, links, images, tables).
|
||||
4. Point out the **Save / Publish** bar: work is saved as a **draft** first, and only goes live when they hit **Publish** — safe to experiment.
|
||||
|
||||
> The rich-text editor for static pages **works today**: type text, make it bold, add headings, bullet lists, links, images, and tables, or switch to a "Code" view for raw HTML.
|
||||
|
||||
## The admin backoffice (running the business)
|
||||
|
||||
Beyond styling, there's a full back-office. In demos, show the **layout and workflow** — the screens are built and polished. Be aware most of these currently run on **sample data** for demo purposes; real live data connects during onboarding (that's a backend integration step, not missing product).
|
||||
|
||||
| Area | What it does | Demo note |
|
||||
|---|---|---|
|
||||
| Dashboard | At-a-glance status: store status, theme, languages, counts, health, recent activity | Cards are live for config; sales counts show "pending backend" until integrated |
|
||||
| Products | Add/edit products: price, variants, images, categories, badges, bulk actions | **Sample data** in demo |
|
||||
| Categories | Category tree with drag-reorder, SEO, translations, soft-delete/restore | **Sample data** in demo |
|
||||
| Orders | Order list/detail, status changes, refunds, cancel, notes, CSV export, invoices | **Sample data** in demo |
|
||||
| Transactions | Payment records, retry failed, fraud flags, audit log, CSV | **Sample data** in demo |
|
||||
| Users & Roles | Team members, roles/permissions, invitations, session/audit history | **Sample data** in demo |
|
||||
| Monitoring | System health + security/event feeds, queues, webhooks | Health is live; feeds are sample |
|
||||
| Analytics | Revenue, orders, top products, plus visitors/funnels | Revenue/orders demo from sample; traffic analytics **Coming soon** |
|
||||
|
||||
## What's polished and worth showing off
|
||||
|
||||
A full UX pass was done across storefront, dashboard, admin, and editor:
|
||||
- Clean, consistent buttons and controls everywhere (one design system).
|
||||
- Smooth, tasteful motion — cards and sections animate in, buttons respond to hover/press — and it automatically respects "reduce motion" accessibility settings.
|
||||
- Works responsively down to phone size.
|
||||
|
||||
## Honest "coming soon" list (don't promise these as live)
|
||||
|
||||
- **Live business data** (real products/orders/customers) — connects per-customer during onboarding; demos use sample data.
|
||||
- **Saving edits to the cloud** — today the editor saves drafts in the browser; server-side save/publish is an onboarding integration.
|
||||
- **Traffic analytics** (visitors, funnels, heatmaps) — the screens exist; the data pipeline is not built yet.
|
||||
- **Per-customer sitemaps / advanced SEO automation** — baseline SEO is in; full automation is roadmap.
|
||||
|
||||
## Quick answers to likely prospect questions
|
||||
|
||||
- **"Do we need a developer to change our store?"** No — the Project Editor covers branding, colors, layout, pages, menus, languages, and feature toggles self-service.
|
||||
- **"Can we have our own domain and branding?"** Yes — each marketplace is its own tenant with its own domain, logo, colors, and content.
|
||||
- **"Multiple languages?"** Yes — add/remove languages in the editor; content is editable per language.
|
||||
- **"Is it safe to experiment?"** Yes — changes are drafts until Published.
|
||||
- **"Is it mobile-friendly?"** Yes — responsive across phone/tablet/desktop.
|
||||
|
||||
## One rule for demos
|
||||
|
||||
If a screen shows sample/placeholder data or a "pending backend" label, say **"this connects to your live data during onboarding"** — it's a real, built screen waiting on integration, not a gap in the product.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Static Pages (Project Editor module)
|
||||
|
||||
Sprint X+2. Full-featured CRUD editor for tenant static content (About, Privacy, Terms, Contacts, custom pages, etc.), living inside the Project Editor at `/edit/static-pages`. Edits `bootstrap.staticPages` directly — the same model the storefront renders from (`docs/BACKEND.md` §3 CRUD Contracts, CMS), no parallel content store.
|
||||
|
||||
`/backoffice/static-pages` (Admin dashboard) redirects here rather than hosting a second CRUD UI over the same data.
|
||||
|
||||
## Where it lives
|
||||
|
||||
```
|
||||
src/app/features/content-management/
|
||||
models/content-page.model.ts ContentPage — the CRUD-facing shape
|
||||
services/content-page.service.ts normalize / resolve / validate / serialize <-> StaticPageConfig
|
||||
facade/content-management.facade.ts
|
||||
components/
|
||||
static-pages-editor.component.* the editor UI (list + per-page card)
|
||||
static-page-preview/ device preview (desktop/tablet/mobile)
|
||||
|
||||
src/app/shared/models/config/static-page.model.ts StaticPageConfig — the bootstrap wire format
|
||||
src/app/features/project-editor/components/html-editor/ MarketplaceHtmlEditorComponent (rich text)
|
||||
src/app/core/config/static-page-resolver.service.ts storefront resolver
|
||||
```
|
||||
|
||||
`ContentPageService` is the single translation layer between the editor's `ContentPage[]` and the bootstrap's `Record<string, StaticPageConfig>` (or the legacy array format) — normalize/resolve/validate/serialize all live there. Nothing else should hand-roll that mapping.
|
||||
|
||||
## Field reference
|
||||
|
||||
### General
|
||||
- `id` — stable key, also the bootstrap record key.
|
||||
- `slug` — used for duplicate-slug detection and as the `route` default.
|
||||
- `route` — **independently editable** from `slug` (defaults from it, but can diverge — e.g. a legacy redirect path). Validated for duplicates against every other page's route.
|
||||
- `enabled` — master on/off switch. A disabled page never resolves on the storefront, regardless of `status`.
|
||||
- Navigation visibility — `showInHeader`, `showInFooter`, `showInSitemap` (independent per-surface flags, unrelated to `enabled`).
|
||||
- `order` — sort position in the editor list and (for footer pages) the auto-generated footer nav group.
|
||||
- `icon` — optional icon identifier.
|
||||
|
||||
### Localization
|
||||
- `title` (per locale, in `translations[locale].title`) and a top-level `title` fallback.
|
||||
- `translations[locale].html` — the rich-text/HTML body, one per supported locale.
|
||||
- `customTemplate` — optional template identifier; consumed by nothing yet (data-only field, forward-compatible with a future template-selection feature).
|
||||
|
||||
### SEO
|
||||
Per top-level `seo` and per-translation `translations[locale].seo` (locale-specific overrides win when resolving): `title`, `description`, `keywords`, `canonical`, `robots`, `ogTitle`, `ogDescription`, `ogImage`.
|
||||
|
||||
### Media
|
||||
- `heroImage`, `thumbnail` — wired through the shared `MediaPickerComponent` (same picker used by Branding/Footer logos).
|
||||
- `gallery: string[]` — a lightweight comma-separated URL list ("future ready" per the brief; no dedicated multi-upload UI yet).
|
||||
|
||||
### Publishing
|
||||
- `status: 'draft' | 'published'` — **per-page** publish lifecycle, independent of the whole-bootstrap draft/publish cycle (see below).
|
||||
- Modified indicator — an "unsaved changes" badge per page, diffed against the originally loaded/published snapshot (`ProjectEditorFacade.originalStaticPages`).
|
||||
|
||||
## The enabled + status gating story
|
||||
|
||||
A static page resolves on the storefront (`ContentPageService.resolvePage`, used by both `StaticPageResolverService` for the page route and `FooterResolverService` for the auto-generated footer nav group) **only when `enabled === true` AND `status === 'published'`.** This is independent of whether the surrounding bootstrap itself has been published — a page marked `draft` stays invisible even after the tenant hits "Publish" on the whole config, and only becomes visible once its own status flips to `published`.
|
||||
|
||||
**Compatibility default:** normalizing existing bootstrap data (loaded from the backend, imported, or read from a legacy array-format `staticPages`) defaults missing `enabled`/`status` to `enabled: true, status: 'published'` — pre-existing pages never get silently un-published by this feature landing. Only the editor's **create-page** action opts a brand-new page into `status: 'draft'` by default, so newly authored content doesn't go live until an author explicitly publishes it.
|
||||
|
||||
The Static Pages editor's own **Live Preview** (desktop/tablet/mobile, `StaticPagePreviewComponent`) intentionally bypasses this gate — it renders straight from the page's current in-memory HTML, so a draft page can still be previewed before publishing.
|
||||
|
||||
## CRUD, search, filter, bulk actions
|
||||
|
||||
- Create, duplicate (clones a page as a new `draft`), delete (confirm dialog), reorder (up/down — not drag-and-drop; see below).
|
||||
- Search across id/slug/route/title (all locales); filter by status (draft/published) or by locale (hides pages missing a translation for the selected locale).
|
||||
- Bulk actions (multi-select checkboxes): delete, enable, disable, publish, unpublish.
|
||||
- **Important implementation detail:** every mutation (create/duplicate/delete/move/bulk) operates on the full, unfiltered page list, never the search/filter-narrowed view — reading from the filtered view before writing back would silently delete whatever the active filter was hiding. See the `persist()` comment in `static-pages-editor.component.ts`.
|
||||
- Reorder is up/down (`move()`), not literal drag handles — a deliberate, lower-complexity scope call; swapping in drag-and-drop later is additive.
|
||||
|
||||
## Validation
|
||||
|
||||
`ContentPageService.validatePages()` returns: `duplicateSlugs`, `duplicateRoutes` (checked independently — a route can diverge from its slug), `emptyTitles`, `invalidHtml` (via `schema/validators/primitives.validateHtml`), `invalidSeo` (canonical/OG-image URL shape, and `robots` against a known-token set: `index`, `noindex`, `follow`, `nofollow`, and their comma-joined combinations). Surfaced as per-page badges in the editor.
|
||||
|
||||
This is layered under (not a replacement for) `ProjectValidator`'s existing platform-wide checks (`docs/EDITOR.md`'s "Configuration schema..." section), which already cover duplicate slugs and cross-surface duplicate routes (pages vs. static pages) at the whole-bootstrap level.
|
||||
|
||||
## Rich text editor
|
||||
|
||||
`MarketplaceHtmlEditorComponent` — see `docs/EDITOR.md`'s "HTML editor (Static Pages)" section for the full toolbar list, the Sprint X+2 additions (horizontal rule, code block, embed), and the HTML-mode validation contract.
|
||||
|
||||
## Navigation integration
|
||||
|
||||
The Navigation section (`navigation-section.component`) has an "Insert page link" control (page picker + button) next to both header and footer "Add link." It creates a `NavigationItemConfig { type: 'staticPage', key: <pageId> }` — a shape the resolvers (`StaticPageResolverService`, `FooterResolverService`) already understood before this sprint; only the editor-side create path was missing. A static-page-linked nav row shows a "Linked to page" indicator instead of editable label/URL fields, since both are derived dynamically from the linked page.
|
||||
|
||||
## Export / import / draft / publish
|
||||
|
||||
No changes to `ProjectEditorIoService` or `ProjectEditorDraftStorageService` — static pages flow through `bootstrap.staticPages` exactly as before, so JSON export/import and the draft-autosave/publish cycle work unmodified. All Sprint X+2 fields are additive and optional at the wire level.
|
||||
@@ -1,7 +0,0 @@
|
||||
# TODO
|
||||
|
||||
No frontend blockers.
|
||||
|
||||
Frontend Release Candidate complete.
|
||||
|
||||
Waiting for backend integration.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Coding Standards
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Keep modules small and cohesive.
|
||||
- Prefer pure functions where possible.
|
||||
- Prefer composition over inheritance.
|
||||
- Prefer configuration over conditionals.
|
||||
- Avoid duplication; extract shared behavior above 70 percent overlap.
|
||||
|
||||
## Type Safety
|
||||
|
||||
- No any in domain and configuration contracts.
|
||||
- Strict typing for API payloads and configuration schemas.
|
||||
- Use discriminated unions for widget and section types.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Errors normalized at service/integration boundaries.
|
||||
- UI displays user-safe messages from facades/view models.
|
||||
- No unhandled promise rejections.
|
||||
|
||||
## API and IO
|
||||
|
||||
- IO is performed in services/integrations only.
|
||||
- Use facades to orchestrate calls and map outputs.
|
||||
- Keep presentation layer side-effect free.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
- Unit tests for facades, services, and mapping logic.
|
||||
- Contract tests for bootstrap schema compatibility.
|
||||
- Boundary tests for forbidden imports.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does this code violate layer boundaries?
|
||||
- Is tenant behavior configuration-driven?
|
||||
- Is logic duplicated and extractable?
|
||||
- Are auth/payment contracts unchanged?
|
||||
- Is the app still compilable?
|
||||
@@ -1,41 +0,0 @@
|
||||
# Component Standards
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Component Categories
|
||||
|
||||
- UI Component: presentational, reusable, stateless or locally visual state only.
|
||||
- Container Component: binds facades and maps view model to UI inputs.
|
||||
- Layout Component: structural composition of sections/widgets.
|
||||
|
||||
## Reusable UI Rules
|
||||
|
||||
A reusable component must:
|
||||
|
||||
- Receive data via Inputs.
|
||||
- Emit user intent via Outputs.
|
||||
- Contain no HttpClient usage.
|
||||
- Contain no localStorage/sessionStorage usage.
|
||||
- Import no environment data.
|
||||
- Have no tenant-specific behavior.
|
||||
- Have no project-name-specific behavior (including legacy variant naming paths).
|
||||
- Have no auth/payment/authorization logic.
|
||||
- Have no route navigation logic.
|
||||
- Render no hardcoded UI copy when translation keys are expected.
|
||||
|
||||
## Container Rules
|
||||
|
||||
- Orchestrate business behavior through facades.
|
||||
- Map facade state into UI-friendly view model.
|
||||
- Handle route and guard interactions.
|
||||
- Never leak domain internals to UI components.
|
||||
|
||||
## Reuse and Duplication Rule
|
||||
|
||||
- If two components share more than 70 percent behavior or template structure, extract reusable component.
|
||||
|
||||
## Accessibility and UX
|
||||
|
||||
- Components must provide semantic markup and keyboard support.
|
||||
- Outputs must represent intent, not implementation details.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Configuration Standards
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Source of Truth
|
||||
|
||||
- All runtime application configuration originates from bootstrap payload.
|
||||
- ConfigService is the only component allowed to load configuration.
|
||||
- No direct JSON loading outside ConfigService.
|
||||
|
||||
## Provider Abstraction
|
||||
|
||||
- Configuration provider must be swappable.
|
||||
- Mock and API providers must return identical schema.
|
||||
- Consumer code remains unchanged when provider changes.
|
||||
|
||||
## Bootstrap Contract Scope
|
||||
|
||||
Bootstrap includes at minimum:
|
||||
|
||||
- Tenant
|
||||
- Branding
|
||||
- Theme
|
||||
- Company
|
||||
- Feature flags
|
||||
- Navigation
|
||||
- Pages, sections, widgets
|
||||
- Localization
|
||||
- SEO
|
||||
- Permissions and capability model
|
||||
- Endpoint descriptors
|
||||
|
||||
## Backend Compatibility
|
||||
|
||||
- Frontend calls GET /bootstrap.
|
||||
- Backend resolves tenant from Host.
|
||||
- Frontend does not send tenant id/project key.
|
||||
|
||||
## Validation and Versioning
|
||||
|
||||
- Bootstrap payload must include schema version.
|
||||
- Validate payload before applying to runtime.
|
||||
- Invalid payload fails fast with controlled fallback.
|
||||
|
||||
## Mock Rules
|
||||
|
||||
- Mock payloads must match future API responses exactly.
|
||||
- No mock-only fields.
|
||||
- No mock-only nesting conventions.
|
||||
|
||||
## Sprint 11.5 Bootstrap Audit Addendum
|
||||
|
||||
- Every configurable website behavior must be representable in bootstrap contracts or widget metadata.
|
||||
- Missing configuration must be documented before implementation work starts.
|
||||
- Frontend teams must not implement backend contract changes in standardization sprints.
|
||||
|
||||
Current documented gaps:
|
||||
|
||||
- Widget role/permission enforcement requires richer auth session claims than currently available.
|
||||
- Catalog popular-search defaults should move from facade constants into bootstrap `catalog` config.
|
||||
- Optional override support for persistent-storage key prefixes is not yet represented in bootstrap schema.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Dependency Rules
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Rule Set
|
||||
|
||||
1. One-way dependency direction only.
|
||||
2. No circular dependencies.
|
||||
3. No feature imports another feature directly.
|
||||
4. Shared is dependency-minimal and feature-agnostic.
|
||||
5. Core is platform base and does not consume feature modules.
|
||||
6. UI Library is pure presentation and cannot depend on facades/services with business behavior.
|
||||
7. Widgets depend on UI Library and contracts, not on feature internals.
|
||||
8. Pages compose layouts/widgets via contracts and facades.
|
||||
9. Facades depend on services/contracts, never on UI components.
|
||||
10. Integrations isolate external systems and expose stable interfaces.
|
||||
|
||||
## Dependency Injection Rules
|
||||
|
||||
- Depend on interfaces/tokens where replacement is expected.
|
||||
- Avoid direct concrete service references across bounded contexts.
|
||||
- Use adapter pattern for legacy stable modules.
|
||||
|
||||
## Cross-Domain Communication
|
||||
|
||||
- Allowed through contracts, events, and facade APIs.
|
||||
- Forbidden through direct state mutation across domains.
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
- Component to HttpClient direct calls in reusable visual components.
|
||||
- Direct environment import in visual components.
|
||||
- Direct localStorage/sessionStorage usage in UI components.
|
||||
- Route navigation logic in UI library components.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Folder Blueprint
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Objective
|
||||
|
||||
Define the target folder layout for the Foundation Phase and all subsequent phases.
|
||||
|
||||
## Blueprint
|
||||
|
||||
src
|
||||
- app
|
||||
- core
|
||||
- bootstrap
|
||||
- providers
|
||||
- loaders
|
||||
- validators
|
||||
- config
|
||||
- application-config.token.ts
|
||||
- config.service.ts
|
||||
- feature-flag.service.ts
|
||||
- runtime
|
||||
- app-runtime.service.ts
|
||||
- platform-context.service.ts
|
||||
- guards
|
||||
- interceptors
|
||||
- error-handling
|
||||
- shared
|
||||
- models
|
||||
- api
|
||||
- config
|
||||
- domain
|
||||
- ui
|
||||
- types
|
||||
- enums
|
||||
- contracts
|
||||
- utils
|
||||
- constants
|
||||
- ui-library
|
||||
- atoms
|
||||
- molecules
|
||||
- organisms
|
||||
- directives
|
||||
- pipes
|
||||
- widgets
|
||||
- registry
|
||||
- contracts
|
||||
- containers
|
||||
- ui
|
||||
- layouts
|
||||
- shells
|
||||
- sections
|
||||
- containers
|
||||
- pages
|
||||
- public
|
||||
- builder
|
||||
- backoffice
|
||||
- features
|
||||
- website
|
||||
- catalog
|
||||
- product
|
||||
- cart
|
||||
- checkout
|
||||
- builder
|
||||
- theme-editor
|
||||
- page-editor
|
||||
- navigation-editor
|
||||
- seo-editor
|
||||
- feature-flag-editor
|
||||
- backoffice
|
||||
- products
|
||||
- categories
|
||||
- orders
|
||||
- customers
|
||||
- inventory
|
||||
- media
|
||||
- settings
|
||||
- facades
|
||||
- website
|
||||
- builder
|
||||
- backoffice
|
||||
- platform
|
||||
- integrations
|
||||
- auth
|
||||
- payment
|
||||
- authorization
|
||||
- theme
|
||||
- tokens
|
||||
- mappers
|
||||
- runtime
|
||||
- dynamic-renderer
|
||||
- page-renderer
|
||||
- section-renderer
|
||||
- widget-host
|
||||
- app.routes.ts
|
||||
- app.config.ts
|
||||
- app.ts
|
||||
- app.html
|
||||
- assets
|
||||
- mock
|
||||
- bootstrap
|
||||
- website
|
||||
- builder
|
||||
- backoffice
|
||||
|
||||
## Foundation Phase Scope
|
||||
|
||||
During Phase 1:
|
||||
|
||||
- Create folder structure and placeholders only.
|
||||
- Do not create business feature implementations.
|
||||
- Keep app runnable and compilable.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
- Contracts and interfaces go to shared models/contracts/types.
|
||||
- Pure visual components go to ui-library.
|
||||
- Configuration-driven blocks go to widgets.
|
||||
- Page composition logic goes to layouts and dynamic-renderer.
|
||||
- Domain orchestration belongs to facades.
|
||||
- Stable auth/payment integrations stay in integrations wrappers.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Import Boundary Matrix
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Allowed Import Matrix
|
||||
|
||||
Legend:
|
||||
|
||||
- Yes: Allowed
|
||||
- No: Forbidden
|
||||
- Limited: Allowed only via published contracts
|
||||
|
||||
| From \ To | Core | Shared | UI Library | Widgets | Layouts | Pages | Features | Facades | Integrations |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Core | Yes | Yes | No | No | No | No | No | No | Limited |
|
||||
| Shared | Yes | Yes | No | No | No | No | No | No | No |
|
||||
| UI Library | Shared only | Yes | Yes | No | No | No | No | No | No |
|
||||
| Widgets | Shared/Core contracts | Yes | Yes | Yes | No | No | No | Limited | No |
|
||||
| Layouts | Shared/Core contracts | Yes | Yes | Yes | Yes | No | No | Limited | No |
|
||||
| Pages | Shared/Core contracts | Yes | Yes | Yes | Yes | Yes | No | Yes | No |
|
||||
| Features | Shared/Core contracts | Yes | Yes | Yes | Yes | Yes | No direct feature-to-feature | Yes | Limited |
|
||||
| Facades | Shared/Core contracts | Yes | No | No | No | No | Limited | Yes | Yes |
|
||||
| Integrations | Shared/Core contracts | Yes | No | No | No | No | No | Limited | Yes |
|
||||
|
||||
## Additional Constraints
|
||||
|
||||
- Features cannot import other features directly.
|
||||
- Shared cannot import any feature, page, layout, widget, or UI layer.
|
||||
- UI Library cannot import facades, integrations, router, or HttpClient.
|
||||
- Core cannot import features.
|
||||
- Circular dependencies are forbidden in all directions.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- Enforce with lint module boundaries.
|
||||
- Enforce with dependency graph checks in CI.
|
||||
- Merge blocked on violations.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Naming Conventions
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## General
|
||||
|
||||
- Use clear domain-oriented names.
|
||||
- Prefer explicit names over abbreviations.
|
||||
- Keep naming consistent across Website, Builder, Backoffice.
|
||||
|
||||
## Files and Folders
|
||||
|
||||
- Folders: kebab-case.
|
||||
- TypeScript files: kebab-case with suffix.
|
||||
- Interfaces: PascalCase.
|
||||
- Types: PascalCase.
|
||||
- Enums: PascalCase.
|
||||
- Constants: UPPER_SNAKE_CASE for true constants.
|
||||
|
||||
## Angular Artifacts
|
||||
|
||||
- Component: name.component.ts
|
||||
- Container component: name.container.component.ts
|
||||
- Facade: name.facade.ts
|
||||
- Service: name.service.ts
|
||||
- Adapter: name.adapter.ts
|
||||
- Token: name.token.ts
|
||||
- Guard: name.guard.ts
|
||||
- Resolver: name.resolver.ts
|
||||
- Pipe: name.pipe.ts
|
||||
|
||||
## Configuration Contracts
|
||||
|
||||
- Bootstrap payload root: BootstrapConfig.
|
||||
- Domain segments named by function:
|
||||
- TenantConfig
|
||||
- BrandingConfig
|
||||
- ThemeConfig
|
||||
- FeatureFlagsConfig
|
||||
- NavigationConfig
|
||||
- PageConfig
|
||||
- SectionConfig
|
||||
- WidgetConfig
|
||||
|
||||
## Event and Action Naming
|
||||
|
||||
- Outputs: actionRequested, valueChanged, selectionChanged.
|
||||
- Facade commands: loadX, updateX, saveX, publishX.
|
||||
- Selectors/signals: xState, xViewModel, isXEnabled.
|
||||
|
||||
## Prohibited Names
|
||||
|
||||
- Generic names without domain meaning such as DataService or UtilsService.
|
||||
- Tenant-coded names in frontend source.
|
||||
- Brand-specific class names in reusable layers.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Marketplace Platform Architecture Foundation
|
||||
|
||||
Status: Approved
|
||||
Owner: Lead Software Architect
|
||||
Date: 2026-07-03
|
||||
|
||||
## Purpose
|
||||
|
||||
This folder defines the mandatory engineering governance for transforming this codebase into a reusable, configuration-driven, multi-tenant Marketplace Platform (Marketplace-as-a-Service).
|
||||
|
||||
This repository is not treated as a single marketplace website.
|
||||
It is a platform runtime that must support unlimited tenants from one Angular application.
|
||||
|
||||
## Platform Principles
|
||||
|
||||
- One codebase, unlimited tenants.
|
||||
- Every tenant has three surfaces: Website, Builder, Backoffice.
|
||||
- Frontend contains no tenant-specific implementation code.
|
||||
- Tenant behavior is controlled by configuration loaded at bootstrap.
|
||||
- Authentication, payment, authorization behavior and contracts remain unchanged.
|
||||
- Prefer composition over inheritance.
|
||||
- Prefer configuration over conditionals.
|
||||
- No circular dependencies.
|
||||
- Shared and UI layers are feature-agnostic.
|
||||
|
||||
## Non-Negotiable Constraints
|
||||
|
||||
- Authentication behavior remains exactly as current implementation.
|
||||
- Payment API behavior remains exactly as current implementation.
|
||||
- Authorization behavior remains exactly as current implementation.
|
||||
- Existing authentication and payment API contracts cannot be changed.
|
||||
- Proven modules are reused, wrapped, and isolated, not redesigned.
|
||||
|
||||
## Document Set
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
- [ADR-001](adr/ADR-001-platform-model.md)
|
||||
- [ADR-002](adr/ADR-002-layered-feature-architecture.md)
|
||||
- [ADR-003](adr/ADR-003-import-boundaries-and-dependency-direction.md)
|
||||
- [ADR-004](adr/ADR-004-configuration-bootstrap-and-provider-abstraction.md)
|
||||
- [ADR-005](adr/ADR-005-dynamic-page-section-widget-rendering.md)
|
||||
- [ADR-006](adr/ADR-006-ui-component-purity-and-container-facade-pattern.md)
|
||||
- [ADR-007](adr/ADR-007-state-management-and-facade-boundaries.md)
|
||||
- [ADR-008](adr/ADR-008-theme-engine-and-design-token-runtime.md)
|
||||
- [ADR-009](adr/ADR-009-feature-flags-and-capability-guards.md)
|
||||
- [ADR-010](adr/ADR-010-backward-compatibility-for-auth-payment-authorization.md)
|
||||
- [ADR-011](adr/ADR-011-optional-seller-management-module.md)
|
||||
|
||||
### Seller Management (optional, in preparation — not built)
|
||||
|
||||
- [Seller-Management.md](Seller-Management.md) — capability overview, start here
|
||||
- [ADR-011](adr/ADR-011-optional-seller-management-module.md) — decision record
|
||||
- [Seller-Management-Diagrams.md](Seller-Management-Diagrams.md) — hierarchy, bootstrap gate, type diagram
|
||||
- [Seller-Management-Domain-Models.md](Seller-Management-Domain-Models.md) — typed models, optional sellerId fields
|
||||
- [Seller-Management-UX-Review.md](Seller-Management-UX-Review.md) — UX/accessibility review of the Phase 1 UI
|
||||
- [Seller-Management-Backoffice-Readiness-Audit.md](Seller-Management-Backoffice-Readiness-Audit.md) — per-module scoping/permissions readiness audit
|
||||
- [Seller-Management-Storefront-Audit.md](Seller-Management-Storefront-Audit.md) — `market.com`/`seller.market.com` storefront readiness audit
|
||||
- [Seller-Management-Backend-Migration-Plan.md](Seller-Management-Backend-Migration-Plan.md) — full backend migration plan, module-by-module, phased
|
||||
- [Seller-Management-Final-Design-Review.md](Seller-Management-Final-Design-Review.md) — principal-architect review, findings, verdict
|
||||
|
||||
### Engineering Rule Documents
|
||||
|
||||
- [Folder Blueprint](Folder-Blueprint.md)
|
||||
- [Import Boundary Matrix](Import-Boundary-Matrix.md)
|
||||
- [Dependency Rules](Dependency-Rules.md)
|
||||
- [Naming Conventions](Naming-Conventions.md)
|
||||
- [Coding Standards](Coding-Standards.md)
|
||||
- [Component Standards](Component-Standards.md)
|
||||
- [Service Standards](Service-Standards.md)
|
||||
- [Configuration Standards](Configuration-Standards.md)
|
||||
- [State Management Standards](State-Management-Standards.md)
|
||||
|
||||
## Compliance
|
||||
|
||||
All new work must comply with this foundation.
|
||||
If an implementation conflicts with these rules, implementation must be adjusted.
|
||||
If a rule must change, an ADR update is required first.
|
||||
|
||||
## Sprint 11.5 Standardization Audit (2026-07-09)
|
||||
|
||||
Platform-wide standardization was executed before Admin Platform work.
|
||||
|
||||
### Completed Standardization
|
||||
|
||||
- Verified and enforced container/facade/domain/infrastructure boundaries across active website features.
|
||||
- Removed remaining legacy variant naming in application-layer templates/styles.
|
||||
- Removed duplicate legacy search-history implementation in catalog feature module.
|
||||
- Standardized design-token surface with explicit spacing, radius, shadow, and transition tokens.
|
||||
- Added widget metadata support for title/subtitle/visibility/layout/animation/style/permissions in shared contracts and dynamic rendering path.
|
||||
- Converted remaining identified hardcoded UI strings in audited runtime pages/components to translation keys.
|
||||
|
||||
### Bootstrap/Configuration Gaps Identified
|
||||
|
||||
- Widget permission model supports auth gating but role/permission enforcement is limited by current auth session shape (no role list in session model).
|
||||
- Popular search defaults are currently facade-local and should be moved to bootstrap-configurable catalog search settings.
|
||||
- Storage key naming conventions for local persistence are platform-scoped but still static constants; optional bootstrap override could improve tenant isolation.
|
||||
|
||||
### Validation Baseline
|
||||
|
||||
- Build and architecture checks are required for acceptance of this sprint.
|
||||
- Final details and file-level changes are tracked in `Platform-Standardization-Report.md`.
|
||||
|
||||
## Mandatory Phase Order
|
||||
|
||||
Implementation must proceed only in this order:
|
||||
|
||||
1. Foundation structure only, app compiles.
|
||||
2. Shared interfaces and types only.
|
||||
3. Mocked configuration payloads only.
|
||||
4. ConfigService abstraction only.
|
||||
5. Theme engine.
|
||||
6. Dynamic rendering engine.
|
||||
7. Reusable widget library.
|
||||
8. Website from configuration.
|
||||
9. Builder from configuration domain.
|
||||
10. Backoffice from business domain.
|
||||
11. Backend documentation.
|
||||
|
||||
For each phase:
|
||||
|
||||
1. Explain what will be created.
|
||||
2. Explain why.
|
||||
3. List files to create.
|
||||
4. Explain dependencies.
|
||||
5. Implement only that phase.
|
||||
6. Verify build integrity.
|
||||
7. Stop and wait.
|
||||
@@ -1,567 +0,0 @@
|
||||
# Seller Management — Backend Migration Plan
|
||||
|
||||
Documentation only. No code, no implementation. This plan synthesizes the
|
||||
three prior audits — do not re-derive facts already established there,
|
||||
read them for detail:
|
||||
|
||||
- [Seller-Management.md](Seller-Management.md) — capability overview,
|
||||
Implemented/Planned/Future legend
|
||||
- [Seller-Management-Backoffice-Readiness-Audit.md](Seller-Management-Backoffice-Readiness-Audit.md)
|
||||
— per-admin-module scoping facts
|
||||
- [Seller-Management-Storefront-Audit.md](Seller-Management-Storefront-Audit.md)
|
||||
— storefront/subdomain facts
|
||||
- `BACKEND.md` §11 — the backend documentation this plan assumes as its
|
||||
starting contract
|
||||
|
||||
**Non-negotiable constraint repeated from the mission, and load-bearing for
|
||||
every classification below:** existing marketplaces without sellers must
|
||||
continue working exactly as today. Seller Management stays optional
|
||||
forever, not just at launch. No endpoint may require seller support unless
|
||||
`modules.sellerManagement.enabled` is true. Every classification and every
|
||||
migration strategy in this document is written to satisfy that constraint
|
||||
first — where a module can't satisfy it without a structural rework, that's
|
||||
called out explicitly, not glossed over.
|
||||
|
||||
## Endpoint classification legend
|
||||
|
||||
- **No change** — endpoint's request/response/behavior is untouched.
|
||||
- **Minor change** — an optional field/parameter added (e.g. `sellerId?` in
|
||||
a filters object or DTO); absent value behaves identically to today.
|
||||
- **Major change** — data model or endpoint semantics change beyond an
|
||||
optional field (e.g. a new join, a new required decision like split
|
||||
orders, a new aggregation dimension).
|
||||
- **New endpoint** — doesn't exist today, net-new surface.
|
||||
|
||||
## Module-by-module
|
||||
|
||||
### Authentication
|
||||
- **Current:** Two live mechanisms (`BACKEND.md` §4) — Telegram/QR
|
||||
customer session login (live), Ed25519 admin challenge/response (built
|
||||
client-side, not wired live). No seller concept in either.
|
||||
- **Future:** `SellerPermissionRole` (`marketplaceOwner`/`seller`/
|
||||
`sellerStaff`/`platformAdmin`) as a role a session can carry, and a
|
||||
decision on whether Seller/Seller Staff authenticate via a third
|
||||
mechanism or reuse Telegram/Ed25519 with a different claim.
|
||||
- **Migration strategy:** additive only — a new optional claim/role field
|
||||
on the existing JWT structure (§4 §3), never a new auth mechanism forced
|
||||
onto existing flows. Existing customer and admin login must not gain any
|
||||
new required step.
|
||||
- **Backward compatibility:** total, if done as an additive claim. A
|
||||
session with no seller claim today is indistinguishable from a session
|
||||
before this work existed.
|
||||
- **Risk:** **Medium** — auth is the most consequence-sensitive surface in
|
||||
the app (ADR-010 froze it once already); any change here needs
|
||||
security review, not just a schema add.
|
||||
- **Effort:** Medium (claim/role addition) once the role-vs-`AdminRole`
|
||||
reconciliation decision (§11.4) is made; that decision itself is the
|
||||
bigger unknown, not the wiring.
|
||||
- **Endpoint classification:** existing login/verify/refresh/logout —
|
||||
**Minor change** (optional claim), contingent on the role decision above.
|
||||
New seller-specific login/enrollment flow (if Seller/Seller Staff need
|
||||
one) — **New endpoint**, Future, not designed.
|
||||
|
||||
### Authorization
|
||||
- **Current:** Coarse `AdminRole` (Owner/Manager/Support/ReadOnly) mapped
|
||||
to `backoffice.read`/`backoffice.write`/`builder.read`/`builder.write`/
|
||||
`settings.manage` (§4 §9.1). No route currently enforces even this — the
|
||||
admin role model exists but nothing gates on it yet (confirmed by the
|
||||
Backoffice audit: "no admin module anywhere does role-based hiding of
|
||||
buttons or data today").
|
||||
- **Future:** `SellerPermissionRole` layered in, deciding whether it
|
||||
extends or sits alongside `AdminRole`.
|
||||
- **Migration strategy:** since authorization enforcement doesn't exist yet
|
||||
even for the current roles, there is no existing enforcement to break —
|
||||
this is genuinely additive design space, not a migration of live
|
||||
behavior.
|
||||
- **Backward compatibility:** trivial today (nothing to preserve because
|
||||
nothing enforces roles yet) but becomes a real compatibility question the
|
||||
moment `AdminRole` enforcement is eventually added — sequence matters:
|
||||
whichever role model ships first should be designed with the other in
|
||||
mind, or the second one becomes a breaking migration of the first.
|
||||
- **Risk:** **Low today, Medium if sequenced wrong** — the risk is entirely
|
||||
about doing `AdminRole` enforcement and `SellerPermissionRole` design in
|
||||
the wrong order, not about either individually.
|
||||
- **Effort:** Low to design (no live behavior to preserve); Medium to
|
||||
implement once both role systems' relationship is decided.
|
||||
- **Endpoint classification:** all admin endpoints — **No change** today
|
||||
(no enforcement exists to change); **Major change** whenever
|
||||
role-enforcement is added generally, seller-aware or not.
|
||||
|
||||
### Bootstrap
|
||||
- **Current:** `GET /bootstrap`, backend resolves tenant by Host (ADR-001,
|
||||
§1). `BootstrapConfig` already has optional `modules?`/`seller?` fields
|
||||
(§11.6), always absent/false — no backend populates them.
|
||||
- **Future:** backend resolving `{seller}.{marketplace-domain}` (§11.5) and
|
||||
populating `modules.sellerManagement.enabled` + `seller` for a real
|
||||
seller-scoped request.
|
||||
- **Migration strategy:** the fields already exist and are optional — the
|
||||
only backend work is *populating* them correctly for a resolved seller
|
||||
scope, not adding new frontend-facing shape. Existing marketplaces'
|
||||
bootstrap responses need zero changes; they simply never populate the
|
||||
new fields.
|
||||
- **Backward compatibility:** already verified — these are optional fields
|
||||
added to a live contract with zero consumer changes required (confirmed
|
||||
`tsc --noEmit` clean at the time they were added).
|
||||
- **Risk:** **Low** — this is the best-prepared module in the whole
|
||||
migration, precisely because the frontend contract was already extended
|
||||
in advance without needing backend cooperation.
|
||||
- **Effort:** Medium — the resolution logic (subdomain → seller lookup) is
|
||||
new backend work, but the response shape is already spoken for.
|
||||
- **Endpoint classification:** `GET /bootstrap` — **Minor change** (two new
|
||||
optional response fields to populate, conditionally).
|
||||
|
||||
### Products
|
||||
- **Current:** No real backend yet (§8) — mock gateway. `AdminProductListFilters`
|
||||
already has `search/categoryId/visibility/stock/includeArchived/sort/page/pageSize`.
|
||||
`Item`/`AdminProduct` already carry optional `sellerId?` (§11.8),
|
||||
read/written by nobody today.
|
||||
- **Future:** `sellerId` filter on list endpoints; ownership enforcement on
|
||||
create/update/delete once a real seller session exists.
|
||||
- **Migration strategy:** add `sellerId` as one more optional field to the
|
||||
existing filters object and DTOs — the field already exists in the
|
||||
frontend type, so there's no frontend change required at all, only
|
||||
backend query logic (`WHERE seller_id = ? OR seller_id IS NULL` pattern
|
||||
or equivalent) gated behind the feature flag.
|
||||
- **Backward compatibility:** guaranteed at the frontend type level
|
||||
already; backend guarantee depends on making the new column/filter
|
||||
nullable and defaulting existing rows to `NULL` (marketplace-owned) —
|
||||
see Database changes below.
|
||||
- **Risk:** **Low** for the filter addition; **Medium** for ownership
|
||||
*enforcement* (a bug here could hide a marketplace owner's own products
|
||||
from themselves, or leak a seller's products to another seller).
|
||||
- **Effort:** Small (list/filter) once the DI-token seam this domain
|
||||
currently lacks (per Backoffice audit) is added — that seam is
|
||||
independent prerequisite work, not seller-specific.
|
||||
- **Endpoint classification:** list/get — **Minor change**. Create/update
|
||||
(ownership assignment) — **Minor change** if `sellerId` is just an
|
||||
optional write field; **Major change** if ownership transfer or
|
||||
seller-side write restrictions are added.
|
||||
|
||||
### Categories
|
||||
- **Current:** The most backend-mature domain — real HTTP already
|
||||
(`AdminCategoriesApiGateway`, DI-token-bound, §3.2). Facade currently
|
||||
hardcodes a full unfiltered fetch and does all real filtering
|
||||
client-side to preserve tree parent/child chains (Backoffice audit
|
||||
finding).
|
||||
- **Future:** categories are conceptually marketplace-wide (shared
|
||||
taxonomy) — the real future question is scoping *products within* a
|
||||
category by seller, not scoping categories themselves.
|
||||
- **Migration strategy:** likely **no backend change at all** for
|
||||
Categories proper; ownership scoping happens one level down, at Products.
|
||||
- **Backward compatibility:** trivial — no change anticipated.
|
||||
- **Risk:** **Low.**
|
||||
- **Effort:** Low (frontend-only fix: the facade's hardcoded full-fetch
|
||||
pattern, unrelated to backend).
|
||||
- **Endpoint classification:** **No change** anticipated.
|
||||
|
||||
### Orders
|
||||
- **Current:** `AdminOrderListFilters` has `search/status/page/pageSize`;
|
||||
`AdminOrder` has optional `sellerId?` (§11.8) at the order level.
|
||||
`AdminOrderItem` (per-line-item) has **no seller attribution field at
|
||||
all** — the concrete gap behind Checkout Modes (§11.7).
|
||||
- **Future:** per-item seller attribution, plus the Unified-vs-Split-Orders
|
||||
decision (§11.7) — the single most consequential undecided item in this
|
||||
entire plan.
|
||||
- **Migration strategy:** cannot be resolved by an additive field alone.
|
||||
**Unified Order** path: add optional `sellerId` to each order-item row
|
||||
(minor, additive). **Split Orders** path: checkout must decide, at
|
||||
creation time, whether to write N order records instead of one for a
|
||||
multi-seller cart — that changes `POST /orders`' semantics for any cart
|
||||
containing mixed-seller items, which is a **major** change regardless of
|
||||
how carefully it's gated, because "how many order records does this
|
||||
checkout produce" is not optional-field-shaped.
|
||||
- **Backward compatibility:** guaranteed for any cart containing only
|
||||
marketplace-owned items (the only kind that exists today) under either
|
||||
path. The compatibility risk is entirely about *new* multi-seller carts,
|
||||
which cannot exist until Products have real seller ownership — so there
|
||||
is a natural sequencing safety net here, not just a promise.
|
||||
- **Risk:** **High** — this is the highest-risk item in the whole plan.
|
||||
Payments, refunds, and financial reporting all depend on the
|
||||
Unified-vs-Split decision; getting it wrong after sellers exist means a
|
||||
breaking change to live financial data, not a code refactor.
|
||||
- **Effort:** Large — this decision should be made and locked before any
|
||||
seller onboarding is possible, not discovered mid-rollout.
|
||||
- **Endpoint classification:** `GET /orders` (list/filter) — **Minor
|
||||
change**. `POST /orders` (create) — **Major change** (semantics change
|
||||
based on the Unified/Split decision). `PATCH` status transitions — likely
|
||||
**Minor change** if orders stay 1:1 with a single seller scope (Split
|
||||
path) or **Major change** if partial per-seller status exists within one
|
||||
unified order.
|
||||
|
||||
### Payments
|
||||
- **Current:** **Frozen** (ADR-010) — Telegram QR/card payment flow,
|
||||
`POST /cart` (`CartPaymentRequest` → `QrCreateResponse`), status polling.
|
||||
Explicitly preserved unchanged through every prior platform-refactoring
|
||||
pass in this codebase's history.
|
||||
- **Future:** if Split Orders is chosen (§ Orders above), a single checkout
|
||||
may need to create multiple payment records or split a captured payment
|
||||
across sellers — a genuinely new payment-flow shape, not a parameter
|
||||
addition.
|
||||
- **Migration strategy:** **do not touch the frozen flow for the Unified
|
||||
Order path** — a unified order with mixed-seller items can still use
|
||||
today's exact single-payment flow, only the *order* record differs
|
||||
internally. Only the Split Orders path forces any payment-flow change,
|
||||
and even then, the existing flow should remain the path for
|
||||
seller-disabled marketplaces with zero exceptions.
|
||||
- **Backward compatibility:** absolute requirement, restated from ADR-010 —
|
||||
this is the one area of the whole codebase with an explicit prior
|
||||
"freeze, do not redesign" decision, and Seller Management does not
|
||||
override it.
|
||||
- **Risk:** **High** if Split Orders is chosen — payment/financial flows
|
||||
are the least forgiving place for a design misstep in this entire system.
|
||||
- **Effort:** Large, only if Split Orders is chosen; **zero** for Unified
|
||||
Order.
|
||||
- **Endpoint classification:** **No change** under Unified Order. **Major
|
||||
change or New endpoint** under Split Orders (undecided, Future).
|
||||
|
||||
### Transactions
|
||||
- **Current:** Derived entirely from the same unscoped Orders list
|
||||
(`AdminTransactionsLocalGateway` mirrors `AdminOrdersLocalGateway`'s
|
||||
pattern, Backoffice audit finding). `AdminTransactionListFilters` has
|
||||
`search/status/type/page/pageSize`.
|
||||
- **Future:** per-seller transaction filtering/reporting.
|
||||
- **Migration strategy:** inherits whatever Orders decides — Transactions
|
||||
cannot be scoped correctly until Orders resolves per-item seller
|
||||
attribution (§ Orders above). Adding a `sellerId` filter here today would
|
||||
be cosmetic without that prerequisite.
|
||||
- **Backward compatibility:** same guarantee as Orders — blocked on the
|
||||
same prerequisite, not an independent risk.
|
||||
- **Risk:** **Medium** — inherits Orders' risk one level removed (financial
|
||||
reporting, not the payment flow itself).
|
||||
- **Effort:** Small once Orders is resolved; not independently schedulable
|
||||
before that.
|
||||
- **Endpoint classification:** **Minor change** (filter field), contingent
|
||||
on Orders' Major change landing first.
|
||||
|
||||
### Reviews
|
||||
- **Current:** Storefront submission is live; admin moderation
|
||||
(`AdminModerationGateway`) is mock-only. `AdminReviewListFilters` has
|
||||
`search/status/rating/page/pageSize`. Reviews carry `productId` — no
|
||||
direct seller field.
|
||||
- **Future:** a seller sees reviews on their own products, via a join
|
||||
through Products' `sellerId`, not a new field on the review itself.
|
||||
- **Migration strategy:** add `sellerId` as a filter that internally joins
|
||||
through the product's ownership — additive at the API surface, but
|
||||
implemented as a join, not a stored column on reviews.
|
||||
- **Backward compatibility:** total — the filter is purely additive and
|
||||
optional.
|
||||
- **Risk:** **Low.**
|
||||
- **Effort:** Small, contingent on Products having real `sellerId` data to
|
||||
join against.
|
||||
- **Endpoint classification:** review list/detail — **Minor change**.
|
||||
Reports (`loadReports()`, currently bare no-arg, no filters object at
|
||||
all) — **Minor change** too, but requires adding a filters parameter
|
||||
that doesn't exist on the endpoint today, so slightly more surface than
|
||||
the reviews list.
|
||||
|
||||
### Analytics
|
||||
- **Current:** No aggregation endpoint exists at all (§3.20) — every
|
||||
number (revenue, top products, health, recommendations) is computed
|
||||
client-side by summing the *entire* orders/products/categories/reviews
|
||||
corpus. This gap exists independent of Seller Management.
|
||||
- **Future:** per-seller analytics, requiring the underlying domains
|
||||
(Orders, Products, Reviews) to be seller-scoped first, then a real
|
||||
aggregation endpoint partitioned by seller.
|
||||
- **Migration strategy:** do not attempt to seller-scope analytics before
|
||||
a real aggregation endpoint exists for the marketplace as a whole — that
|
||||
is a prerequisite gap, not a Seller Management task. Once it exists,
|
||||
seller-partitioning is an additional dimension on the same aggregation
|
||||
query, not a new endpoint family.
|
||||
- **Backward compatibility:** unaffected — a marketplace with no sellers
|
||||
gets marketplace-wide aggregates exactly as it always has, whenever the
|
||||
real aggregation endpoint is eventually built.
|
||||
- **Risk:** **Medium** — mostly the risk of building the wrong aggregation
|
||||
shape before seller-partitioning is even a consideration, and having to
|
||||
redo it.
|
||||
- **Effort:** Large — this is explicitly the last domain recommended for
|
||||
real backend work in `BACKEND.md` §9, and Seller Management adds a
|
||||
further dimension on top of an already-large lift.
|
||||
- **Endpoint classification:** **New endpoint** (the aggregation endpoint
|
||||
itself doesn't exist yet, seller-aware or not).
|
||||
|
||||
### Media
|
||||
- **Current:** `MediaRepository` abstract class, DI-token-bound already
|
||||
(mock live, `ApiMediaRepository` not yet written). `MediaListParams`
|
||||
already accepts `page/pageSize/search/folder/kind/sort`. `MediaAsset` has
|
||||
no owner/uploader field at all.
|
||||
- **Future:** `sellerId` filter, requiring an owner field added to
|
||||
`MediaAsset` first.
|
||||
- **Migration strategy:** two small additive changes — a new optional
|
||||
`sellerId`/`ownerId` field on the asset model, and a matching optional
|
||||
filter field on `MediaListParams`. This module and Categories are the
|
||||
two best-positioned in the entire audit for a low-risk addition.
|
||||
- **Backward compatibility:** total — both changes are optional-field
|
||||
additions to an already-flexible params object.
|
||||
- **Risk:** **Low.**
|
||||
- **Effort:** Small.
|
||||
- **Endpoint classification:** list — **Minor change**. Upload/update
|
||||
(§7) — **Minor change** (one new optional field in the request body).
|
||||
|
||||
### Search
|
||||
- **Current:** Standard search contract exists (§2.5) as a framework
|
||||
convention (keyword, no dedicated backend endpoint beyond catalog list
|
||||
filtering — `/search` reuses the same catalog container/endpoint as
|
||||
`/catalog` per the Storefront audit). No autocomplete/trending endpoint
|
||||
exists; explicitly flagged as its own open "Requires backend decision"
|
||||
in §2.12.
|
||||
- **Future:** filtering search results by seller scope, same mechanism as
|
||||
Products (search is not architecturally distinct from catalog browsing
|
||||
today).
|
||||
- **Migration strategy:** inherits Products' migration entirely — no
|
||||
separate search-specific backend work anticipated beyond what Products
|
||||
already needs.
|
||||
- **Backward compatibility:** total, same guarantee as Products.
|
||||
- **Risk:** **Low.**
|
||||
- **Effort:** None beyond Products — not independently schedulable.
|
||||
- **Endpoint classification:** **Minor change**, identical to Products'
|
||||
classification (same underlying endpoint).
|
||||
|
||||
### CMS (Static Pages)
|
||||
- **Current:** No backend call at all — reads/writes
|
||||
`BootstrapConfig.staticPages` in-memory (§1, §3.8). Marketplace-wide
|
||||
content (legal, about) by nature.
|
||||
- **Future:** **conceptually does not apply** — no per-seller static page
|
||||
concept exists in this document or in product intent. If sellers ever
|
||||
need their own content pages, that is new product surface, not an
|
||||
extension of this module.
|
||||
- **Migration strategy:** none anticipated. Flag if product strategy later
|
||||
decides otherwise — treat as a new capability, not a CMS migration.
|
||||
- **Backward compatibility:** unaffected — no change proposed.
|
||||
- **Risk:** **None.**
|
||||
- **Effort:** **None.**
|
||||
- **Endpoint classification:** **No change.**
|
||||
|
||||
### Builder
|
||||
- **Current:** No backend write path at all (§1.10, §8) — draft/publish is
|
||||
`localStorage`-only, editing one global `BootstrapConfig` document per
|
||||
marketplace. The single strongest one-owner assumption in the codebase
|
||||
(Backoffice audit finding).
|
||||
- **Future:** a seller-scoped builder (per-seller storefront layout/
|
||||
branding) would be an entirely new product surface built on a different
|
||||
premise than "one config document per marketplace" — not an extension of
|
||||
the existing builder.
|
||||
- **Migration strategy:** none proposed for the existing Builder. If a
|
||||
seller-facing builder is ever wanted, it should be scoped as its own ADR
|
||||
and its own backend surface, not bolted onto the marketplace Builder's
|
||||
existing draft/publish contract.
|
||||
- **Backward compatibility:** unaffected — no change proposed to the
|
||||
existing module.
|
||||
- **Risk:** **None** for the existing module; **High** if a future team
|
||||
attempts to retrofit seller-awareness into the existing single-document
|
||||
model rather than building fresh — flagged explicitly as an
|
||||
anti-pattern to avoid.
|
||||
- **Effort:** **None** now; a seller-facing builder, if ever built, is a
|
||||
large, independent effort, not a migration of this one.
|
||||
- **Endpoint classification:** **No change** to the existing (still
|
||||
nonexistent) builder write path. Any future seller-builder surface is
|
||||
**New endpoint**, Future, not designed.
|
||||
|
||||
### Settings
|
||||
- **Current:** No route exists — `comingSoon: true` in the admin nav, no
|
||||
component backs it (confirmed, Backoffice audit).
|
||||
- **Future:** if Settings is ever built, it's the natural home for
|
||||
marketplace-level Seller Management configuration (e.g. enabling the
|
||||
module, default seller policies) — speculative, not designed.
|
||||
- **Migration strategy:** none — there's nothing to migrate.
|
||||
- **Backward compatibility:** not applicable.
|
||||
- **Risk:** **None.**
|
||||
- **Effort:** **None** attributable to Seller Management specifically.
|
||||
- **Endpoint classification:** **No change** — no endpoint exists.
|
||||
|
||||
### Notifications
|
||||
- **Current:** A marketplace-facing feature flag exists
|
||||
(`FeatureFlagsConfig.notifications: boolean`) but this gates a storefront
|
||||
UI feature, not a backend notification/email service — no such service
|
||||
exists in this codebase for anyone today.
|
||||
- **Future:** seller onboarding (invitation accepted, application
|
||||
approved/rejected) would need real notification delivery — entirely
|
||||
net-new, not an extension of the existing flag.
|
||||
- **Migration strategy:** none — nothing exists to migrate.
|
||||
- **Backward compatibility:** not applicable.
|
||||
- **Risk:** **Low** (net-new work carries normal build risk, not migration
|
||||
risk).
|
||||
- **Effort:** Medium, whenever built — but zero today and not a
|
||||
prerequisite for anything else in this plan.
|
||||
- **Endpoint classification:** **New endpoint**, Future, not designed.
|
||||
|
||||
### Emails
|
||||
- **Current:** No email-sending capability exists anywhere in this
|
||||
codebase.
|
||||
- **Future:** the mocked "Request Access" form on the Phase 1 UI
|
||||
(`admin-seller-management-page.component.ts`) implies a real email would
|
||||
eventually be sent on submission — today it's a `setTimeout`-mocked
|
||||
success dialog with zero delivery.
|
||||
- **Migration strategy:** none — nothing exists to migrate.
|
||||
- **Backward compatibility:** not applicable.
|
||||
- **Risk:** **Low** (net-new, not migration).
|
||||
- **Effort:** Small to Medium, whenever built (transactional email for one
|
||||
form submission is a contained scope).
|
||||
- **Endpoint classification:** **New endpoint**, Future, not designed.
|
||||
|
||||
### Audit Logs
|
||||
- **Current:** Documented in `BACKEND.md` §5.14 as a proposed convention
|
||||
(structured audit-log entries with actor/action/target/timestamp) — no
|
||||
backend implementation confirmed to exist; this is itself already
|
||||
Planned/Future in the base backend doc, independent of Seller
|
||||
Management.
|
||||
- **Future:** any seller-specific action (seller created, activated,
|
||||
suspended, branding changed) should flow through the same audit-log
|
||||
convention once it exists, with an added seller-scope dimension on the
|
||||
log entry — not a parallel logging system.
|
||||
- **Migration strategy:** none specific to Seller Management beyond
|
||||
ensuring, whenever audit logging is built, that its schema includes an
|
||||
optional seller-scope field from day one rather than retrofitting it
|
||||
later.
|
||||
- **Backward compatibility:** not applicable — nothing exists yet to
|
||||
preserve.
|
||||
- **Risk:** **Low.**
|
||||
- **Effort:** None additional if sequenced correctly (add the field when
|
||||
audit logging is first built); Medium if audit logging ships first
|
||||
without it and needs a schema migration later.
|
||||
- **Endpoint classification:** **New endpoint** (audit log query surface,
|
||||
if one is ever exposed to admins) — Future, not designed.
|
||||
|
||||
## Database changes
|
||||
|
||||
No schema exists yet for any of this (§8 — no backend implemented at all).
|
||||
Recommendations, not decisions:
|
||||
|
||||
- A `sellers` table (id, marketplace_id, name, slug, status, branding
|
||||
JSON/columns, timestamps) — new table, no impact on existing schema.
|
||||
- A nullable `seller_id` foreign key on `products`/`items` and `orders` (or
|
||||
order-items, pending the Unified/Split decision) — nullable by
|
||||
design, defaulting existing rows to `NULL` (marketplace-owned). This is
|
||||
the one schema change that touches existing tables; every other addition
|
||||
is a new table.
|
||||
- A nullable `seller_id`/`owner_id` on the media-assets table, if Media
|
||||
ownership scoping is pursued.
|
||||
- No column should ever be added as `NOT NULL` without a default — that
|
||||
would force a backfill migration on existing data, which this plan's
|
||||
constraint explicitly rules out.
|
||||
|
||||
## Permission changes
|
||||
|
||||
- New `sellers` and `seller_users` (or equivalent) authorization checks —
|
||||
entirely new policy, not a modification of existing `AdminRole` checks
|
||||
(which, per the Backoffice audit, don't enforce anything yet regardless).
|
||||
- Existing admin/customer authorization paths: **no change** required for
|
||||
marketplaces with `modules.sellerManagement.enabled = false`.
|
||||
- Recommendation: implement seller-scoped authorization as an additional
|
||||
policy layer evaluated only when a request resolves to a seller scope
|
||||
(§11.5) — never as a modification of the existing marketplace-level
|
||||
policy evaluation path.
|
||||
|
||||
## Caching
|
||||
|
||||
- Bootstrap responses are already cached client-side per tenant (§1.7); a
|
||||
seller-scoped bootstrap response should be cached under a cache key that
|
||||
includes the resolved seller identity (e.g. keyed by full resolved Host,
|
||||
which it already effectively is), not the marketplace alone — otherwise
|
||||
a seller subdomain risks serving a cached marketplace-level response or
|
||||
vice versa.
|
||||
- No existing cache invalidation logic needs to change for marketplaces
|
||||
without sellers.
|
||||
|
||||
## Indexes
|
||||
|
||||
- `sellers(marketplace_id)` — every seller lookup is scoped by marketplace.
|
||||
- `products(seller_id)` / `orders(seller_id)` (or order-items equivalent)
|
||||
— nullable-column indexes; most databases index `NULL` efficiently, but
|
||||
this should be verified against the chosen database engine before
|
||||
assuming query performance is unaffected for the common (`NULL`) case.
|
||||
- No index changes required on any table for marketplaces that never
|
||||
populate `seller_id`.
|
||||
|
||||
## Security
|
||||
|
||||
- Seller-scoped data access is a new cross-tenant-adjacent boundary (a
|
||||
seller must never see another seller's data within the same
|
||||
marketplace) — recommend treating this with the same rigor as tenant
|
||||
isolation (§5.10/§4 §10), not as a lesser internal boundary.
|
||||
- The existing frozen payment flow (ADR-010) must not be reopened for the
|
||||
Unified Order path — only the Split Orders path (if chosen) touches
|
||||
payment security surface at all, and that touch should get the same
|
||||
security review rigor as the original payment implementation.
|
||||
|
||||
## Performance
|
||||
|
||||
- Analytics is already the heaviest computation path in the app (full
|
||||
client-side aggregation over the entire corpus, §3.20) — building the
|
||||
real aggregation endpoint this plan's Analytics section calls for should
|
||||
happen with seller-partitioning in mind from the start, to avoid a
|
||||
second heavy migration shortly after the first.
|
||||
- No performance regression is anticipated for marketplaces without
|
||||
sellers — every proposed change is either a new table/endpoint (zero
|
||||
cost when unused) or a nullable-field filter (negligible cost when the
|
||||
filter is never applied).
|
||||
|
||||
## API Versioning
|
||||
|
||||
Already documented as an open, undecided item in `BACKEND.md` §2.10 (no
|
||||
path/header versioning scheme exists today). Recommendation specific to
|
||||
this migration: whichever versioning decision is made generally should
|
||||
land **before** any Seller Management endpoint ships, so new endpoints
|
||||
(Seller CRUD, Activation, Invitations, Branding, Analytics, Dashboard) are
|
||||
versioned consistently with the rest of the API from their first day,
|
||||
rather than being retrofitted later as the one inconsistent set.
|
||||
|
||||
## Migration order
|
||||
|
||||
Strict dependency order, not a preference:
|
||||
|
||||
1. **Bootstrap** (already prepared, lowest risk) — backend starts
|
||||
populating `modules`/`seller` fields for a resolved seller scope.
|
||||
2. **Products** — `seller_id` column + filter, the foundation every
|
||||
downstream domain depends on.
|
||||
3. **Categories** — confirm no change needed (likely true, low effort to
|
||||
verify).
|
||||
4. **Media** — independent, can happen in parallel with Products.
|
||||
5. **Reviews** — depends on Products (join through product ownership).
|
||||
6. **Orders** — the Unified-vs-Split-Orders decision must be made here,
|
||||
informed by Products already existing. This is the hard gate — nothing
|
||||
past this point should start before it's resolved.
|
||||
7. **Payments** — only touched if Split Orders is chosen; otherwise
|
||||
untouched, in parallel with everything above.
|
||||
8. **Transactions** — depends on Orders.
|
||||
9. **Analytics** — depends on Orders, Products, Reviews all being scoped;
|
||||
also depends on the pre-existing (non-seller-specific) real aggregation
|
||||
endpoint being built first.
|
||||
10. **Authentication/Authorization** — the `SellerPermissionRole` design
|
||||
and its relationship to `AdminRole` should be settled in parallel with
|
||||
steps 2-6, not deferred to the end, since Seller Activation and any
|
||||
real seller-facing endpoint depend on it.
|
||||
11. **Seller CRUD/Activation/Invitations/Branding** — depends on
|
||||
Authentication/Authorization being settled.
|
||||
12. **Seller Dashboard/Analytics endpoints** — last, depends on Analytics'
|
||||
real aggregation endpoint existing.
|
||||
13. **Notifications/Emails/Audit Logs** — can start any time after step 11
|
||||
(Seller Invitations existing gives them something to notify about);
|
||||
not blocking anything else.
|
||||
14. **CMS, Builder, Settings** — no migration anticipated; revisit only if
|
||||
product strategy changes.
|
||||
|
||||
## Recommended implementation phases
|
||||
|
||||
- **Phase A — Foundation** (steps 1-4, 10 above): bootstrap population,
|
||||
Products/Categories/Media scoping, and the permission-role design done
|
||||
in parallel. Nothing customer-visible yet. Lowest risk, unblocks
|
||||
everything else.
|
||||
- **Phase B — The hard decision** (step 6, Orders): Unified-vs-Split-Orders
|
||||
locked before any real seller can transact. This phase is a design
|
||||
decision plus its implementation, not a feature — treat it as a gate,
|
||||
not a sprint item.
|
||||
- **Phase C — Dependent domains** (steps 5, 7, 8): Reviews, Payments (if
|
||||
Split chosen), Transactions — mechanical once Phase B lands.
|
||||
- **Phase D — Seller-facing surface** (step 11): Seller CRUD, Activation,
|
||||
Invitations, Branding — the first point where a seller account can
|
||||
actually exist and do something.
|
||||
- **Phase E — Analytics & reporting** (steps 9, 12): the largest single
|
||||
remaining lift, deliberately last since it depends on everything above.
|
||||
- **Phase F — Operational polish** (step 13): Notifications, Emails, Audit
|
||||
Logs — improves the experience of Phase D/E but blocks nothing.
|
||||
|
||||
At every phase boundary: re-verify the non-negotiable constraint. A
|
||||
marketplace with `modules.sellerManagement.enabled = false` must show zero
|
||||
behavioral difference before and after each phase ships. If a phase can't
|
||||
satisfy that, it isn't ready to ship — regardless of how much of it is
|
||||
"done."
|
||||
@@ -1,319 +0,0 @@
|
||||
# Seller Management — Backoffice Readiness Audit
|
||||
|
||||
Audit only. **No code changed by this pass** — every fact below was gathered
|
||||
by reading current source (facades, gateway interfaces, local
|
||||
implementations, components) on `feature/seller-management-foundation`, not
|
||||
inferred or assumed. Companion to
|
||||
[Seller-Management.md](Seller-Management.md) §5 (Roles & Permissions) and §6
|
||||
(Seller Ownership).
|
||||
|
||||
## How to read this
|
||||
|
||||
For each module: three readiness questions, then **where** a future scope
|
||||
would be injected (not "if seller" conditionals — an injection point in an
|
||||
existing method signature or facade call), then components that currently
|
||||
assume there is exactly one owner of the whole dataset, then a
|
||||
classification.
|
||||
|
||||
**Classification legend:**
|
||||
- **Ready** — either already scope-injectable with no structural change, or
|
||||
conceptually marketplace-wide data that a seller scope shouldn't apply to
|
||||
at all.
|
||||
- **Needs scope** — a filters object or DI-token seam already exists; adding
|
||||
a scope field is additive, not structural.
|
||||
- **Needs permissions** — the open question is *who sees this at all*
|
||||
(Marketplace Owner vs Seller vs Seller Staff vs Platform Admin), not data
|
||||
filtering.
|
||||
- **Needs API change** — the method signature itself has no parameter to
|
||||
extend (bare no-arg calls), the data model has no owner attribution field
|
||||
to filter on, or the module's whole premise assumes one global record.
|
||||
|
||||
Repo-wide baseline established by this audit: **only Categories, Dashboard-
|
||||
metrics, and Media have a DI-token-swappable gateway today** (per
|
||||
`BACKEND.md` §8's pattern — interface + `*LocalGateway` + `*ApiGateway` +
|
||||
`InjectionToken`). Every other admin domain's facade injects its
|
||||
`*LocalGateway` concretely; that seam has to be added before any real
|
||||
scoping work lands, independent of the scoping question itself. No module
|
||||
currently does role-based hiding of any button or data — Users displays
|
||||
role/permission *labels* only, nothing gates on them.
|
||||
|
||||
## Module-by-module
|
||||
|
||||
### Dashboard
|
||||
- Owner sees everything? Yes — today's only mode.
|
||||
- Seller sees only their own? Not possible today — `loadMetrics()` has no
|
||||
parameters at all.
|
||||
- Seller Staff limited? Undetermined — no permission model touches this yet.
|
||||
- **Injection point:** `AdminDashboardMetricsGateway.loadMetrics()` would
|
||||
need a new parameter (interface change, not a filters-object field —
|
||||
there is no object to extend).
|
||||
- **Assumes global ownership:** `admin-dashboard-metrics.local.gateway.ts`
|
||||
computes `categoriesCount`/`productsCount` as raw `.length` over the
|
||||
entire catalog.
|
||||
- **Classification: Needs API change.**
|
||||
|
||||
### Products
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Not yet, but the shape is close — filters
|
||||
object already exists.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** `AdminProductListFilters` (already has
|
||||
`search/categoryId/visibility/stock/includeArchived/sort/page/pageSize`)
|
||||
— an optional `sellerId` field slots in next to the existing ones; one
|
||||
more `.filter()` line in `AdminProductsLocalGateway`.
|
||||
- **Assumes global ownership:** `loadDashboardStats()` calls
|
||||
`loadProducts({page:1, pageSize:100000, includeArchived:true, ...})` to
|
||||
sum stock/health stats with no per-seller split.
|
||||
- **Classification: Needs scope** (small, once the DI-token seam this
|
||||
module still lacks is added — see baseline note above).
|
||||
|
||||
### Categories
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Categories are conceptually a **shared,
|
||||
marketplace-wide taxonomy** — the real future question is "which products
|
||||
in category X belong to seller Y," not "which categories belong to seller
|
||||
Y." Likely Owner-only editable regardless of seller scope.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** `AdminCategoryListFilters` already has
|
||||
`search/visibility/includeDeleted` and the gateway is already DI-token-
|
||||
swappable (`ADMIN_CATEGORIES_GATEWAY`) — **but** the facade's `loadList()`
|
||||
currently calls the gateway with a hardcoded
|
||||
`{search:'', visibility:'all', includeDeleted:true}` and does all real
|
||||
filtering client-side in `filteredCategories()`/`visibleTreeRows()` to
|
||||
preserve tree parent/child chains. A `sellerId` filter passed to the
|
||||
gateway would be silently bypassed unless this hardcoded call is updated
|
||||
too.
|
||||
- **Assumes global ownership:** `loadDashboardStats()` sums the whole
|
||||
category tree.
|
||||
- **Classification: Needs scope** — gateway/interface layer is Ready, the
|
||||
facade's full-fetch-then-client-filter pattern is the actual gap.
|
||||
|
||||
### Orders
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? **Not modeled at all** — `AdminOrderItem` has
|
||||
no seller/vendor attribution field; a multi-vendor order (one order,
|
||||
items from several sellers) has no representation today. This is the
|
||||
concrete blocker behind the Unified-vs-Split-Orders open question in
|
||||
`Seller-Management.md` §6.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** `AdminOrderListFilters` has `search/status/page/
|
||||
pageSize` — a `sellerId` field is syntactically cheap to add, but
|
||||
filtering by it means nothing until orders/order-items carry seller
|
||||
attribution in the data model.
|
||||
- **Assumes global ownership:** `loadDashboardStats()` fetches
|
||||
`pageSize:100000` and sums orders/revenue/customers globally; this same
|
||||
full-fetch feeds Customers, Transactions, and Analytics (see below),
|
||||
compounding the single-owner assumption across four modules.
|
||||
- **Classification: Needs API change** — the data-model gap (per-item
|
||||
seller attribution, unified-vs-split decision) is the real blocker, not
|
||||
the filter object.
|
||||
|
||||
### Customers
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Not possible today — "customer" is a
|
||||
**derived aggregate** grouping the full order list by email in-memory;
|
||||
there is no first-class Customers gateway to add a filter to at all.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** none exists yet. Either (a) derive from an
|
||||
already-scoped Orders call once Orders itself supports `sellerId`, or (b)
|
||||
introduce a first-class `AdminCustomersGateway` — `BACKEND.md` already
|
||||
recommends the latter independent of Seller Management.
|
||||
- **Assumes global ownership:** `buildCustomers()` groups the entire
|
||||
unscoped order list; hardcoded `pageSize:100000` fetch, "search" is
|
||||
client-side only.
|
||||
- **Classification: Needs API change.**
|
||||
|
||||
### Users
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Not modeled — `loadUsers()` is bare no-arg,
|
||||
no filters object exists to extend.
|
||||
- Seller Staff limited? **This is where the answer will actually live** —
|
||||
`AdminUser` already carries an `AdminUserScope` field (`'marketplace' |
|
||||
'office'` in seed data) and an `AdminRole` with a permission-array shape.
|
||||
This is the closest existing hook to the future `SellerPermissionRole`
|
||||
vocabulary (`marketplaceOwner`/`seller`/`sellerStaff`/`platformAdmin`,
|
||||
`Seller-Management.md` §5) — but today it's used for labels only
|
||||
(`roleLabel`/`permissionLabel` in the page component), nothing gates
|
||||
actions or visibility on it anywhere in the app.
|
||||
- **Injection point:** `loadUsers()` needs a parameter added (interface
|
||||
change — no object to extend); `AdminUserScope` is the natural place a
|
||||
seller-scope value would eventually live.
|
||||
- **Assumes global ownership:** `loadAll()` fetches every user
|
||||
unconditionally, no per-seller user set exists.
|
||||
- **Classification: Needs API change** (data fetch) **+ Needs permissions**
|
||||
(this module is the eventual home of the Marketplace Owner / Seller /
|
||||
Seller Staff / Platform Admin distinction — right now it's purely
|
||||
informational).
|
||||
|
||||
### Analytics
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Not possible today — every number (revenue,
|
||||
top products, health, recommendations) is summed across the *entire*
|
||||
orders+products+categories+reviews corpus with no per-owner dimension
|
||||
anywhere, and there's no gateway of its own to add a filter to (it
|
||||
composes five other gateways/facades directly).
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** none today — depends entirely on Orders/Products/
|
||||
Categories/Moderation each supporting `sellerId` first, then every one of
|
||||
this facade's ~6 nested subscribe calls would need the field passed
|
||||
through, plus every aggregate builder (`buildSeries`, `buildTopProducts`,
|
||||
`buildCustomerAnalytics`) reworked to partition by seller instead of
|
||||
summing globally.
|
||||
- **Assumes global ownership:** the strongest case in the audit — literally
|
||||
every displayed number.
|
||||
- **Classification: Needs API change** — `BACKEND.md` already independently
|
||||
flags this as the last domain to get a real backend; Seller Management
|
||||
scoping compounds on top of that, not ahead of it.
|
||||
|
||||
### Reviews / Moderation
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Reviews carry `productId`/`productName` — a
|
||||
seller would see reviews on *their own products*, which means joining
|
||||
through product ownership (once Products has `sellerId`), not a direct
|
||||
seller field on the review itself. Reports (`loadReports()`) has no
|
||||
filters object at all today, separate code path from reviews.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** `AdminReviewListFilters`
|
||||
(`search/status/rating/page/pageSize`) — cheap field add, correctness
|
||||
depends on a product-ownership join. `loadReports()` needs a signature
|
||||
change first (no params exist).
|
||||
- **Assumes global ownership:** `loadDashboardStats()` sums the full review
|
||||
queue with `pageSize:100000`, no per-product/per-seller split.
|
||||
- **Classification: Needs scope** (reviews, small, blocked on Products)
|
||||
**+ Needs API change** (reports, no params today).
|
||||
|
||||
### Media
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Not modeled — `MediaAsset` has no
|
||||
uploader/owner field at all (`filename/mimeType/size/tags/folder` only);
|
||||
"folder" is the closest existing scoping primitive, used generically
|
||||
today.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** `MediaListParams` (`page/pageSize/search/folder/
|
||||
kind/sort`) is already a rich optional-params object — a `sellerId` field
|
||||
is a cheap addition, and the repository is already DI-token-bound
|
||||
(`MediaRepository` abstract class, mock vs API swap already established).
|
||||
`MediaAsset` itself needs an owner field added for the filter to mean
|
||||
anything.
|
||||
- **Assumes global ownership:** none beyond the missing owner field itself.
|
||||
- **Classification: Needs scope** (small — best-positioned module in the
|
||||
audit alongside Categories).
|
||||
|
||||
### CMS / Static Pages
|
||||
- Owner sees everything? Yes — and likely always will.
|
||||
- Seller sees only their own? **Conceptually doesn't apply.** Static pages
|
||||
(legal, about, etc.) are inherently marketplace-wide; there is no
|
||||
per-seller "static page" concept in the domain, and no fetch method
|
||||
exists to add a filter to in the first place — this module reads/writes
|
||||
`BootstrapConfig.staticPages` in-memory, no gateway, no HTTP call
|
||||
(confirmed by `BACKEND.md`: "has no backend call today").
|
||||
- Seller Staff limited? Not applicable.
|
||||
- **Injection point:** none — would require inventing an entirely new data
|
||||
source, not adding a filter to an existing one.
|
||||
- **Assumes global ownership:** the whole module's premise, but
|
||||
appropriately so — this is marketplace-wide content by nature.
|
||||
- **Classification: Ready** — no scoping work belongs here; flag if product
|
||||
strategy later decides sellers need their own static pages, which is a
|
||||
new capability, not a gap in this one.
|
||||
|
||||
### Builder / Project Editor
|
||||
- Owner sees everything? Yes — the entire module edits one global
|
||||
`BootstrapConfig` document.
|
||||
- Seller sees only their own? Not modeled, and not a filter question at
|
||||
all — there is no `load*`/`list*` gateway method anywhere in this module
|
||||
to extend; `save()`/`publish()`/`resetDraft()` all operate on the single
|
||||
in-memory config object, with no backend write path yet either
|
||||
(`BACKEND.md`: "no client write call exists today").
|
||||
- Seller Staff limited? Not applicable at this structural level.
|
||||
- **Injection point:** none exists. A seller-scoped builder (per-seller
|
||||
storefront layout/branding) would be a **different product concept**, not
|
||||
an extension of this module's current single-document model.
|
||||
- **Assumes global ownership:** total and structural — the single strongest
|
||||
one-owner assumption in the codebase.
|
||||
- **Classification: Needs API change** — biggest structural gap in the
|
||||
audit if seller-level storefront customization is ever wanted; this is
|
||||
new work, not scoping.
|
||||
|
||||
### Settings
|
||||
No route exists — `admin-nav.model.ts` marks it `comingSoon: true`, no
|
||||
component backs it. **Skipped, nothing to audit.**
|
||||
|
||||
### Monitoring
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Likely **shouldn't** — events/queues/webhooks
|
||||
(logins, API health, queue depth) are platform-operational data. A seller
|
||||
has no legitimate reason to see other users' login events or system
|
||||
queue depth regardless of any future scoping.
|
||||
- Seller Staff limited? Same reasoning — this looks like a Marketplace
|
||||
Owner / Platform Admin-only surface once roles exist, not something a
|
||||
Seller role should reach at all.
|
||||
- **Injection point:** `AdminMonitoringEventFilters` (`category/search`)
|
||||
exists for events; `loadQueues()`/`loadWebhooks()` are bare no-arg.
|
||||
- **Assumes global ownership:** appropriately so — this is genuinely
|
||||
system-wide data.
|
||||
- **Classification: Needs permissions** — the open question is route-level
|
||||
visibility per role, not data filtering.
|
||||
|
||||
### Transactions
|
||||
- Owner sees everything? Yes.
|
||||
- Seller sees only their own? Not modeled — transactions are derived 1:1
|
||||
from the same unscoped global order list as Customers, inheriting the
|
||||
same seller-attribution gap.
|
||||
- Seller Staff limited? Undetermined.
|
||||
- **Injection point:** `AdminTransactionListFilters`
|
||||
(`search/status/type/page/pageSize`) exists — cheap field syntactically,
|
||||
but real correctness is blocked on Orders resolving per-item seller
|
||||
attribution first.
|
||||
- **Assumes global ownership:** derives wholesale from
|
||||
`AdminOrdersLocalGateway`, same pattern as Customers.
|
||||
- **Classification: Needs scope** (small syntactically, blocked on Orders'
|
||||
**Needs API change** classification for real correctness).
|
||||
|
||||
## Summary table
|
||||
|
||||
| Module | Classification |
|
||||
|---|---|
|
||||
| Dashboard | Needs API change |
|
||||
| Products | Needs scope |
|
||||
| Categories | Needs scope |
|
||||
| Orders | Needs API change |
|
||||
| Customers | Needs API change |
|
||||
| Users | Needs API change + Needs permissions |
|
||||
| Analytics | Needs API change |
|
||||
| Reviews / Moderation | Needs scope (reviews) + Needs API change (reports) |
|
||||
| Media | Needs scope |
|
||||
| CMS / Static Pages | Ready (not applicable) |
|
||||
| Builder / Project Editor | Needs API change |
|
||||
| Settings | N/A — no route |
|
||||
| Monitoring | Needs permissions |
|
||||
| Transactions | Needs scope (blocked on Orders) |
|
||||
|
||||
**Nothing in this repo is currently classified "Ready" for actual seller
|
||||
data scoping** — CMS/Static Pages is "Ready" only in the sense that it
|
||||
correctly needs no scoping at all. Categories and Media are the
|
||||
best-positioned modules for a future scope field (filters object + DI seam
|
||||
either fully or mostly in place already). Orders is the load-bearing
|
||||
blocker — Customers, Transactions, and half of Analytics all derive from
|
||||
it, so its data-model gap (no per-item seller attribution, unified-vs-split
|
||||
undecided) should be resolved before scoping any of its three dependents.
|
||||
|
||||
## Cross-cutting findings
|
||||
|
||||
- **No admin module anywhere does role-based hiding of buttons or data
|
||||
today.** Users is the only module with a role *concept* in its data
|
||||
(`AdminRole`, permission arrays) and even there it's label-only.
|
||||
- **Only 3 of 13 audited gateways are DI-token-swappable today**
|
||||
(Categories, Dashboard-metrics, Media) — everything else needs that seam
|
||||
added before any scoping work, independent of Seller Management.
|
||||
- **The heaviest single dependency chain**: Orders → Customers,
|
||||
Transactions, and Analytics all derive from the same unscoped, full-fetch
|
||||
order list. Fixing Orders' data model is the one change with the largest
|
||||
downstream effect.
|
||||
- **Two modules are structurally not about data scoping at all**: CMS/
|
||||
Static Pages (marketplace-wide by nature) and Builder/Project Editor
|
||||
(single global document, no query surface) — seller-level work here
|
||||
would be new product surface, not an extension.
|
||||
- **No "if seller" conditional exists anywhere in the codebase** — this
|
||||
audit deliberately did not introduce any. Every injection point above is
|
||||
described as a parameter/field addition to an existing method or
|
||||
interface, never a runtime branch.
|
||||
@@ -1,72 +0,0 @@
|
||||
# Seller Management — Architecture Diagrams
|
||||
|
||||
Companion diagrams for [ADR-011](adr/ADR-011-optional-seller-management-module.md).
|
||||
Architecture only — no UI, no backend, no business logic exists yet.
|
||||
|
||||
## 1. Hierarchy
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Platform["Platform<br/>(one Angular runtime)"]
|
||||
Marketplace["Marketplace (tenant)<br/>always present · backend-resolved from Host<br/>ADR-001"]
|
||||
SellerA["Seller A<br/>optional, 0..N"]
|
||||
SellerB["Seller B<br/>optional, 0..N"]
|
||||
NoSeller["No sellers<br/>(default — most marketplaces today)"]
|
||||
|
||||
Platform --> Marketplace
|
||||
Marketplace --> SellerA
|
||||
Marketplace --> SellerB
|
||||
Marketplace -.default state.-> NoSeller
|
||||
```
|
||||
|
||||
Marketplace is the only primary tenant. Seller is a child scope of exactly
|
||||
one marketplace — never a sibling tier, never resolved on its own.
|
||||
|
||||
## 2. Bootstrap module gate
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Request["GET /bootstrap"] --> Backend["Backend resolves:<br/>tenant (always)<br/>seller (only if applicable)"]
|
||||
Backend --> Bootstrap["BootstrapConfig"]
|
||||
Bootstrap --> ModulesCheck{"modules.sellerManagement.enabled?"}
|
||||
ModulesCheck -->|false / absent, default| Identical["Behavior identical to today.<br/>No new routes, menus, or API calls."]
|
||||
ModulesCheck -->|true| Available["Seller-aware behavior becomes available<br/>(not built yet — future work, own ADR)"]
|
||||
Bootstrap -.optional field.-> SellerField["BootstrapConfig.seller<br/>(SellerConfig, present only when<br/>backend resolved a seller scope)"]
|
||||
```
|
||||
|
||||
The frontend performs no resolution — it reads whatever the backend already
|
||||
decided into `BootstrapConfig.modules` / `BootstrapConfig.seller`, exactly
|
||||
the same discipline as tenant resolution (ADR-001) and feature-flag gating
|
||||
(ADR-009).
|
||||
|
||||
## 3. Type contracts introduced (this ADR only)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class BootstrapConfig {
|
||||
+TenantConfig tenant
|
||||
+PlatformModulesConfig? modules
|
||||
+SellerConfig? seller
|
||||
...existing fields unchanged
|
||||
}
|
||||
class PlatformModulesConfig {
|
||||
+SellerManagementModuleConfig sellerManagement
|
||||
}
|
||||
class SellerManagementModuleConfig {
|
||||
+boolean enabled
|
||||
}
|
||||
class SellerConfig {
|
||||
+UUID id
|
||||
+UUID marketplaceId
|
||||
+string slug
|
||||
+string name
|
||||
+string defaultLocale
|
||||
+string[] supportedLocales
|
||||
}
|
||||
BootstrapConfig --> PlatformModulesConfig
|
||||
BootstrapConfig --> SellerConfig
|
||||
PlatformModulesConfig --> SellerManagementModuleConfig
|
||||
```
|
||||
|
||||
`modules` and `seller` are both optional on `BootstrapConfig`. Every field
|
||||
already on `BootstrapConfig` is untouched.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Seller Management — Domain Models (Preparation)
|
||||
|
||||
Companion to [ADR-011](adr/ADR-011-optional-seller-management-module.md) and
|
||||
[Seller-Management-Diagrams.md](Seller-Management-Diagrams.md). This document
|
||||
covers the second preparation pass: typed domain models for a future Seller
|
||||
entity, and optional seller-ownership fields on existing Product/Order
|
||||
models. **Typed models only — no repository, gateway, facade, CRUD, API, or
|
||||
authentication/authorization change exists as a result of this work.**
|
||||
|
||||
## New: `core/sellers/models/`
|
||||
|
||||
A new domain model group, mirroring the existing `core/products/models/`,
|
||||
`core/auth/models/` convention. Nothing outside this directory imports from
|
||||
it yet — these types exist for future work to build against.
|
||||
|
||||
| Type | File | Purpose |
|
||||
|---|---|---|
|
||||
| `MarketplaceRef` | `marketplace-ref.model.ts` | Minimal `{id, slug, name}` reference from a seller record back to its owning marketplace. Not a replacement for `TenantConfig` (bootstrap's runtime tenant contract, ADR-001) — just enough to say which marketplace a seller belongs to. |
|
||||
| `SellerStatus` | `seller-status.model.ts` | Lifecycle vocabulary: `'pending' \| 'active' \| 'suspended' \| 'disabled'`. No transition logic. |
|
||||
| `SellerScope` | `seller-scope.model.ts` | `{sellerId, marketplaceId}` — the domain-level counterpart to `BootstrapConfig.seller` (`SellerConfig`). Backend-resolved only, same rule as tenant resolution (ADR-001, ADR-011). |
|
||||
| `SellerBranding` (+ `SellerContact`, `SellerAddress`, `SellerThemeOverrides`) | `seller-branding.model.ts` | Logo, banner, description, contacts, address, theme overrides — **all fields optional**. Absent means marketplace branding/theme applies, unchanged (`BrandingConfig`/`ThemeConfig`). Nothing consumes this yet. |
|
||||
| `SellerPermissionRole`, `SellerPermissions` | `seller-permissions.model.ts` | Four future roles: `marketplaceOwner`, `seller`, `sellerStaff`, `platformAdmin`. **A separate vocabulary from the existing `AdminRole`** (Owner/Manager/Support/ReadOnly, `core/auth/models/permission.model.ts`) — not merged, not wired into any guard, no auth behavior change. |
|
||||
| `Seller` | `seller.model.ts` | The eventual entity: `id`, `marketplace: MarketplaceRef`, `name`, `slug`, `status: SellerStatus`, optional `branding: SellerBranding`, `createdAt`/`updatedAt`. |
|
||||
|
||||
All exported via `core/sellers/models/index.ts`.
|
||||
|
||||
## Changed: optional seller ownership on existing entities
|
||||
|
||||
Three existing entities gained one new **optional** field each. In every
|
||||
case: absent = marketplace-owned (today's only reality for every existing
|
||||
product/order), nothing reads the field yet, no consumer needed updating,
|
||||
`tsc`/`arch:check` both verified clean after the change.
|
||||
|
||||
| Entity | File | Field added |
|
||||
|---|---|---|
|
||||
| `Item` (storefront product) | `models/item.model.ts` | `sellerId?: string` |
|
||||
| `AdminProduct` (admin product editor) | `features/admin/products/models/admin-product.model.ts` | `sellerId?: string` |
|
||||
| `AdminOrder` (admin order editor) | `features/admin/orders/models/admin-order.model.ts` | `sellerId?: string` |
|
||||
|
||||
Deliberately **not** touched: `AdminOrderItem` (per-line-item seller
|
||||
ownership is a finer-grained decision than this preparation pass covers —
|
||||
order-level `sellerId` is enough for now), and both existing bootstrap
|
||||
`PermissionsConfig`/`AdminRole` (no authentication change, per mission).
|
||||
|
||||
## Non-goals (explicitly out of scope)
|
||||
|
||||
- No repository, gateway, facade, or API call reads or writes `sellerId`,
|
||||
`Seller`, or any type in this document.
|
||||
- No route, guard, or UI surfaces any of this.
|
||||
- No change to `AdminRole`, `ROLE_PERMISSIONS`, or any existing
|
||||
authentication/authorization code path.
|
||||
- No change to marketplace branding/theme defaults or precedence — a
|
||||
marketplace with no sellers, or a seller with no branding overrides,
|
||||
behaves exactly as today.
|
||||
|
||||
## What this unblocks later
|
||||
|
||||
Once Seller Management is actually implemented (its own ADR/implementation
|
||||
pass, per ADR-011 §"Scope of this ADR"): a `SellerRepository`/`SellerGateway`
|
||||
can return `Seller` objects instead of inventing a shape; product/order
|
||||
CRUD can start populating `sellerId` without a breaking schema change;
|
||||
permission guards can consume `SellerPermissionRole` once a real role system
|
||||
decision is made; branding resolution can check `Seller.branding` before
|
||||
falling back to marketplace `BrandingConfig`/`ThemeConfig`.
|
||||
@@ -1,190 +0,0 @@
|
||||
# Seller Management — Final Design Review
|
||||
|
||||
Principal-architect-level review of everything built and documented for
|
||||
Seller Management so far. One real bug found and fixed (below, in scope per
|
||||
this mission's "unless absolutely required" carve-out); everything else is
|
||||
findings only, no further code changed. Reviewed against source directly
|
||||
(fresh `tsc --noEmit` and `arch:check` run for this review, not recalled
|
||||
from memory) plus every doc in the series:
|
||||
[Seller-Management.md](Seller-Management.md),
|
||||
[ADR-011](adr/ADR-011-optional-seller-management-module.md),
|
||||
[Seller-Management-Diagrams.md](Seller-Management-Diagrams.md),
|
||||
[Seller-Management-Domain-Models.md](Seller-Management-Domain-Models.md),
|
||||
[Seller-Management-UX-Review.md](Seller-Management-UX-Review.md),
|
||||
[Seller-Management-Backoffice-Readiness-Audit.md](Seller-Management-Backoffice-Readiness-Audit.md),
|
||||
[Seller-Management-Storefront-Audit.md](Seller-Management-Storefront-Audit.md),
|
||||
[Seller-Management-Backend-Migration-Plan.md](Seller-Management-Backend-Migration-Plan.md),
|
||||
and `BACKEND.md` §11.
|
||||
|
||||
## Checklist verification (evidence-based, not asserted)
|
||||
|
||||
| Item | Status | Evidence |
|
||||
|---|---|---|
|
||||
| No existing marketplace breaks | ✓ Verified | Fresh `tsc --noEmit` clean, fresh `arch:check` clean (this review); live browser tests at 3 separate checkpoints across storefront home + backoffice dashboard/route |
|
||||
| Feature is optional | ✓ Verified | `DEFAULT_PLATFORM_MODULES_CONFIG.sellerManagement.enabled = false`; no backend anywhere sets it true |
|
||||
| Bootstrap remains backward compatible | ✓ Verified | `modules?`/`seller?` both optional on `BootstrapConfig`; `tsc` stayed clean the moment they were added, no consumer touched |
|
||||
| No API breaking changes | ✓ Verified (trivially) | No API exists to break — documentation-only for the backend side |
|
||||
| Existing frontend continues working | ✓ Verified | Live-tested at every UI-touching commit, zero regressions found |
|
||||
| Existing backend continues working | N/A | No backend exists; not applicable until implementation begins |
|
||||
| Dependency direction (ADR-002) | ✓ Verified | `arch:check:boundaries` clean, run fresh for this review |
|
||||
| Import boundaries (ADR-003) | ✓ Verified | Same tool, same clean result |
|
||||
| No tenant-specific conditions | ✓ Verified | Reviewed every new file directly — zero `if (tenant...)`/`if (seller===...)` conditionals exist anywhere |
|
||||
| Seller scope is additive | ✓ Verified | Every new field on every touched type is optional; nothing required changed |
|
||||
| UI consistency | ✓ Verified, with 2 fixes already applied | Dedicated UX-review pass found and fixed a label/a11y gap and a native-bullet inconsistency |
|
||||
| Translation readiness | ✓ Verified | en/ru/hy all carry every new key, confirmed by exact-count grep |
|
||||
| Accessibility readiness | ✓ Verified structurally, **one caveat** | Confirmed via accessibility-tree inspection (`role=dialog`, `aria-modal`, focus trap, `aria-label`) — **no real screen-reader software (NVDA/VoiceOver) pass was ever done**, only automated tree inspection. Flagged below (Low). |
|
||||
| Performance considerations | ✓ Verified | New route is its own lazy chunk (confirmed in build output), doesn't touch the initial bundle |
|
||||
| Future scalability | ✓ Addressed at design level | `Seller-Management-Backend-Migration-Plan.md` covers indexes, caching, phased rollout |
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. `SellerConfig` vs. `Seller`/`SellerBranding` — two unreconciled type hierarchies — **Medium**
|
||||
|
||||
`shared/models/config/seller.model.ts` (`SellerConfig`, the bootstrap wire
|
||||
shape: `id, marketplaceId, slug, name, defaultLocale, supportedLocales`) and
|
||||
`core/sellers/models/seller.model.ts` (`Seller`, the domain entity:
|
||||
`id, marketplace: MarketplaceRef, name, slug, status, branding?,
|
||||
createdAt, updatedAt`) describe overlapping concepts with different shapes
|
||||
and no conversion function between them. This was **self-identified during
|
||||
this same body of work** (`BACKEND.md` §11.6 already flags it as an open
|
||||
question) — restating it here as an independently-confirmed architectural
|
||||
finding, not a new discovery, because a final design review should not
|
||||
let a self-flagged gap quietly become "someone else's problem later."
|
||||
**Recommendation:** resolve before real backend work starts — either
|
||||
`SellerConfig` becomes a strict projection of `Seller` (documented mapping),
|
||||
or they're merged into one type with bootstrap-specific fields marked
|
||||
optional. Either is fine; leaving it unreconciled through implementation
|
||||
risks two competing "seller" shapes drifting further apart.
|
||||
|
||||
### 2. No reusable capability-guard abstraction exists — **Medium**
|
||||
|
||||
ADR-011 (and ADR-009 before it) both prescribe checking a capability flag
|
||||
"in one place, not scattered conditionals." In practice, **no such
|
||||
reusable guard exists anywhere in this codebase** — not for
|
||||
`sellerManagement.enabled`, and not for any existing feature flag either.
|
||||
ADR-009 itself describes a `FeatureFlagService` that was never actually
|
||||
built (confirmed: no file of that name exists in `src/app/core`). The one
|
||||
current consumer (`AdminSellerManagementPageComponent`) hand-rolls the
|
||||
optional-chain read directly. With one consumer this is harmless; the
|
||||
moment a second consumer needs the same check, it will either duplicate
|
||||
the same expression (drift risk: `?? false` vs `=== true` vs missing a
|
||||
null-check) or someone will need to build the guard ADR-009 already
|
||||
promised. **Recommendation:** build one small `SellerManagementGuardService`
|
||||
(or equivalent) the first time a second consumer needs the flag — don't
|
||||
let a third or fourth hand-rolled copy accumulate first.
|
||||
|
||||
### 3. Reactive-signal bug in the Phase 1 page — **Fixed during this review**
|
||||
|
||||
`AdminSellerManagementPageComponent.sellerManagementEnabled` read
|
||||
`configService.getBootstrapSnapshot()` once via a plain `signal()` at
|
||||
construction time — not reactively tied to `configService.bootstrapRevision()`
|
||||
the way `UiRuntimeFacade` and `SeoService` both correctly do. If bootstrap
|
||||
ever reloaded after initial page load (tenant context switch, revalidation)
|
||||
with the flag now `true`, this signal would never update — a real
|
||||
staleness bug, currently invisible because the flag is always `false` and
|
||||
the signal was never even read in the template. **Fixed in this review**
|
||||
(changed to `computed()` keyed on `bootstrapRevision()`, matching the
|
||||
established codebase pattern exactly) — a one-line correctness fix to
|
||||
already-committed code, not new feature work, so it fell inside this
|
||||
mission's "unless absolutely required" carve-out. Verified `tsc --noEmit`
|
||||
clean after the change.
|
||||
|
||||
### 4. `sellerId` typed as bare `string`, not `UUID` — **Low / Nice to have**
|
||||
|
||||
`Item.sellerId?`, `AdminProduct.sellerId?`, `AdminOrder.sellerId?` are all
|
||||
typed `string`, while every ID in the new `core/sellers/models/` uses the
|
||||
`UUID` type alias (`type UUID = string` — functionally identical, purely a
|
||||
signaling convention used consistently elsewhere in this codebase, e.g.
|
||||
`TenantConfig.id: UUID`). Zero functional impact since `UUID` is a bare
|
||||
alias, but a future reader will reasonably wonder why the new sellerId
|
||||
fields didn't follow the convention the sellers domain itself established
|
||||
one file away. **Recommendation:** trivial fix, do it opportunistically
|
||||
next time any of these three files is touched — not worth a dedicated pass.
|
||||
|
||||
### 5. `MarketplaceRef` vs. `TenantConfig` — acceptable but worth flagging — **Low**
|
||||
|
||||
`MarketplaceRef {id, slug, name}` and the existing `TenantConfig` (id, slug,
|
||||
code, host, name, locales, currencies, timezone, base URLs) both represent
|
||||
"a marketplace," from two different vantage points (seller-record reference
|
||||
vs. full runtime tenant contract). This is a deliberate, documented
|
||||
distinction (`Seller-Management-Domain-Models.md` explains it), not an
|
||||
accidental duplication — but it's the kind of decision that reads clearly
|
||||
today and could easily read as "why are there two Marketplace types" to
|
||||
someone joining later without the context. **Recommendation:** no action
|
||||
needed now; if a third marketplace-shaped type is ever proposed, that's the
|
||||
signal to consolidate, not before.
|
||||
|
||||
### 6. The flag's "true" branch has never been exercised, even manually — **Medium**
|
||||
|
||||
Every verification claim in this document's checklist table (and every
|
||||
prior audit) was tested with `modules.sellerManagement.enabled` at its
|
||||
real-world value: `false` (or absent). **Nobody has ever manually set it to
|
||||
`true`** — not in a browser dev-tools override, not in a mock fixture — to
|
||||
confirm the flag-reading code path actually behaves as intended when the
|
||||
condition it exists to detect is met. Today that's low-stakes (there's no
|
||||
enabled-state UI to differ), but the review checklist item "Seller scope is
|
||||
additive" was verified by reading the code, not by observing the `true`
|
||||
branch execute. **Recommendation:** the first time any enabled-state UI is
|
||||
built, that's also the moment to add one manual (or fixture-based) test
|
||||
confirming the `true` path — don't let a second feature get built on top of
|
||||
an assumption that was never actually observed.
|
||||
|
||||
### 7. Documentation-to-code ratio is unusually high — **Low / Nice to have**
|
||||
|
||||
Eight documents (this one included) exist for a capability that has zero
|
||||
backend bytes and one placeholder frontend page. That's not inherently
|
||||
wrong — the mission explicitly asked for staged documentation-first work —
|
||||
but it carries two real risks worth naming: (a) maintenance burden keeping
|
||||
eight cross-linked documents consistent if any single decision changes
|
||||
(e.g., if Unified-vs-Split-Orders resolves one way, at least three of these
|
||||
docs reference it and would need a coordinated update), and (b) the more
|
||||
times an undecided item ("Future," "not designed") is repeated across
|
||||
documents, the more it can start to feel settled by sheer repetition even
|
||||
though nothing has actually been decided. **Recommendation:** before
|
||||
backend implementation begins, do one consolidation pass collapsing
|
||||
overlapping content (the Unified/Split-Orders question in particular
|
||||
appears in `Seller-Management.md`, the Storefront audit, `BACKEND.md` §11,
|
||||
and the Migration Plan) into a single canonical statement the others link
|
||||
to, rather than four independent restatements.
|
||||
|
||||
### 8. No automated test coverage — **Low / Nice to have, not new**
|
||||
|
||||
Zero unit or integration tests cover any file introduced in this work —
|
||||
consistent with the rest of the codebase (`PROJECT_STATUS.md` already
|
||||
documents "no automated test suite exists," a pre-existing, repo-wide gap,
|
||||
not something this work introduced or made worse). Noting it here for
|
||||
completeness, not as a Seller-Management-specific defect.
|
||||
|
||||
## What I did NOT find
|
||||
|
||||
No architectural weaknesses beyond the above. Specifically checked for and
|
||||
did **not** find: circular dependencies (verified fresh, clean), scattered
|
||||
tenant/seller conditionals (none exist anywhere), over-engineering relative
|
||||
to the "typed models only" mandate (the six new model files map 1:1 to the
|
||||
six concepts explicitly requested, nothing extra), hidden coupling between
|
||||
`core/sellers/models` and any `features/` folder (the new domain models
|
||||
import only from `shared/types`, nothing reaches into a feature module),
|
||||
or any security-sensitive code path touched (no auth, no payment code was
|
||||
modified anywhere in this entire body of work).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Not an unqualified "ready for implementation."** Two Medium findings
|
||||
(#1, the unreconciled `SellerConfig`/`Seller` type split, and #2, the
|
||||
missing capability-guard abstraction) are genuine architectural loose ends
|
||||
that should be resolved by decision or by a small build, respectively,
|
||||
before real backend/CRUD work begins — not because either blocks anything
|
||||
today, but because both compound in cost the longer they're left
|
||||
unresolved (more consumers = more places to reconcile later; the type
|
||||
duality especially, since a real `SellerRepository`/`SellerGateway` would
|
||||
otherwise have to pick one shape or invent a mapping ad hoc under time
|
||||
pressure). Finding #6 (the flag's true-branch never observed) is a
|
||||
process gap to close at the next milestone, not before it.
|
||||
|
||||
**Everything that has actually been built — the typed foundation, the
|
||||
disabled-by-default feature flag, the Phase 1 UI, and the one real bug
|
||||
this review found and fixed — is solid and ready to stay exactly as it
|
||||
is.** The design as a *whole plan* is sound and internally consistent; the
|
||||
two Medium findings are refinements to make before the next phase starts,
|
||||
not defects in what exists today. No Critical or High-severity issue was
|
||||
found anywhere in this review.
|
||||
@@ -1,197 +0,0 @@
|
||||
# Seller Management — Storefront Audit (`market.com` / `seller.market.com`)
|
||||
|
||||
Audit only, no code changed. Every fact below was gathered by reading
|
||||
current source on `feature/seller-management-foundation`, not assumed.
|
||||
Companion to [Seller-Management.md](Seller-Management.md) §6 (Seller
|
||||
Storefronts, Seller Branding — both marked Future there) and
|
||||
[Seller-Management-Backoffice-Readiness-Audit.md](Seller-Management-Backoffice-Readiness-Audit.md)
|
||||
(the equivalent admin-side audit).
|
||||
|
||||
## The core question: does the architecture already support a seller subdomain?
|
||||
|
||||
**Yes, at the resolution layer — no frontend change needed there.** Tenant
|
||||
resolution is entirely backend-side by request Host (ADR-001): the frontend
|
||||
calls `GET /bootstrap` and renders whatever comes back, with no client-side
|
||||
knowledge of what hostname it's running on beyond what it reads from
|
||||
`window.location`. If a backend ever resolves `seller.market.com` to a
|
||||
seller-scoped bootstrap response, the frontend's fetch-and-render pipeline
|
||||
doesn't need to know that happened — it already just consumes
|
||||
`BootstrapConfig`. **This is the single most important finding of this
|
||||
audit**: the hard problem is not "can the frontend handle a second
|
||||
hostname" (it already architecturally can, by design), it's "does the
|
||||
*data* the frontend renders (branding, breadcrumbs, SEO, contact info)
|
||||
carry a seller-aware value to render instead of the marketplace-wide one."
|
||||
That's a bootstrap-response and per-surface question, audited area by area
|
||||
below — not a routing or hosting question.
|
||||
|
||||
## Global structure
|
||||
|
||||
Every storefront route sits under one `:lang` prefix (`app.routes.ts`) —
|
||||
`/${lang}/catalog/:id`, `/${lang}/product/:id`, `/${lang}/wishlist`,
|
||||
`/${lang}/cart`, `/${lang}/search`. No seller/tenant segment exists in the
|
||||
route tree today, and none needs to for the subdomain approach — the
|
||||
hostname carries the seller scope, not a path segment. `/search` is not a
|
||||
distinct page — it lazy-loads the same `CatalogContainerComponent` as
|
||||
`/catalog`. **Checkout is not a separate route or component** — it's a
|
||||
payment-popup flow inline inside `cart.component.ts`
|
||||
(`features/website/checkout/` and `features/website/cart/` are empty
|
||||
placeholder folders, `.gitkeep` only).
|
||||
|
||||
## Area by area
|
||||
|
||||
### Homepage — Ready structurally
|
||||
`pages/home/home.component.ts` is a thin wrapper: gets a page-render model
|
||||
from `WebsiteRuntimeFacade.getPageRenderModelForUrl()` and renders it. No
|
||||
branding/URL logic of its own, nothing hardcoded. **Future:** a seller-scoped
|
||||
home would need `WebsiteRuntimeFacade`'s page-model resolution to become
|
||||
seller-aware — out of scope of this file, belongs to whichever facade
|
||||
resolves the page model once a seller bootstrap concept exists.
|
||||
|
||||
### Categories / Search — single injection point identified
|
||||
`features/website/catalog/containers/catalog-container.component.ts`
|
||||
(shared by both `/catalog` and `/search`) builds its own breadcrumb via
|
||||
`CategoryFacade.getBreadcrumb()` → `CategoryTreeUtils.getBreadcrumb()` — pure
|
||||
category-parent-chain traversal, no marketplace-root assumption baked into
|
||||
the algorithm itself, but no seller dimension either. Reads
|
||||
`catalogConfig`/`userExperienceConfig` straight from the bootstrap snapshot
|
||||
(marketplace-wide today). **Future — Seller breadcrumbs:** since **no
|
||||
dedicated breadcrumb component or service exists anywhere in the
|
||||
storefront** (confirmed repo-wide — this is the only breadcrumb logic that
|
||||
exists at all), a future "Seller X > Category > Product" trail has exactly
|
||||
one call site to touch: this component's `breadcrumb` signal build.
|
||||
|
||||
### Products — reviews live here too, one dead SEO hook found
|
||||
`features/website/product/containers/product-details-container.component.ts`.
|
||||
Product URLs and share links (`shareProduct()`) are built from
|
||||
`window.location.origin` dynamically, never hardcoded. Reviews and Q&A
|
||||
render inside this same container (`ReviewListComponent`,
|
||||
`QuestionListComponent`) — **there is no separate Reviews page/route to
|
||||
audit independently.** **Real finding, not seller-specific but directly
|
||||
relevant:** `SeoService.setItemMeta(item)` — the method that would set
|
||||
per-product OG/canonical tags — is defined but **never called anywhere in
|
||||
the codebase**. Product pages today get only the site-wide default meta
|
||||
tags, not per-product ones. **Future — Seller SEO on product pages**
|
||||
depends on first wiring this already-existing but currently dead hook, not
|
||||
on inventing a new one.
|
||||
|
||||
### Favorites / Wishlist — Ready, no change needed today
|
||||
`features/website/user-experience/wishlist/containers/wishlist-page.component.ts`.
|
||||
Thin list view, no branding, no URL construction, no SEO. **Future:** if
|
||||
sellers want their own "favorited from my store" filtering, that's a filter
|
||||
on the already-prepared `Item.sellerId?` field (`Seller-Management-Domain-Models.md`)
|
||||
applied at read time — no structural change to this page.
|
||||
|
||||
### Cart / Checkout — one literal string worth flagging
|
||||
`pages/cart/cart.component.ts`. Checkout logic (`openPaymentPopup`,
|
||||
`createPayment`, status polling) lives entirely in this one file — there is
|
||||
no separate checkout page. `getPaymentDescription()` falls back, in order:
|
||||
`bootstrap.branding.brandName` → `TenantResolverService.getHostname()`
|
||||
(non-localhost) → the literal string `'Покупка на Маркетплейсе'`
|
||||
("Purchase on Marketplace") for the payment-provider's description field.
|
||||
This is a generic last-resort fallback, not a hardcoded brand name, but it
|
||||
is single-tenant-framed. `recordOrder()` posts to `apiService.createOrder`
|
||||
with no explicit marketplace/seller field — backend infers via Host
|
||||
(ADR-001), same pattern as everywhere else. **Future — this is where
|
||||
Checkout Modes and Unified/Split Orders (both marked Future, undecided, in
|
||||
`Seller-Management.md` §6) would actually land**: today one cart always
|
||||
produces one order via one popup flow; a cart containing items from
|
||||
multiple sellers has no defined behavior here at all yet.
|
||||
|
||||
### Header — clean, single injection point for branding
|
||||
`components/header/header.component.ts`. `brandName` →
|
||||
`UiRuntimeFacade.marketplaceDisplayName()`; `logo` →
|
||||
`UiRuntimeFacade.logoUrl()`. Both fully dynamic, sourced from
|
||||
`bootstrap.branding` via `UiRuntimeFacade.reloadFromBootstrap()`
|
||||
(`facades/runtime/ui-runtime.facade.ts`). No hardcoded name/logo anywhere.
|
||||
`homeUrl` is `/${lang}` (root-relative — correct as-is for a seller
|
||||
subdomain, since the subdomain itself carries the scope, not a path
|
||||
segment). **Future — Seller logo / Seller banner / Seller branding**: this
|
||||
facade's `reloadFromBootstrap()` method is the **single highest-leverage
|
||||
place to add a seller-branding override** — if `bootstrap.seller?.branding`
|
||||
(the already-typed but unused `SellerBranding`, `Seller-Management-Domain-Models.md`)
|
||||
is ever populated, this one method could prefer it over
|
||||
`bootstrap.branding` before setting facade state, and Header/Footer would
|
||||
pick it up automatically with zero changes of their own, since they already
|
||||
read exclusively through this facade.
|
||||
|
||||
### Footer — same source as Header, plus contact info
|
||||
`components/footer/footer.component.ts`. `brandName` →
|
||||
`uiRuntime.marketplaceName()`, `contactEmail` → `uiRuntime.contactEmail()` —
|
||||
identical facade source as Header. Footer link groups/payment icons come
|
||||
from `FooterResolverService.resolveFooterModelFromBootstrap()`, entirely
|
||||
bootstrap-driven, nothing hardcoded. **Future — Seller contact page**: no
|
||||
such page or concept exists today; the footer currently only ever surfaces
|
||||
one marketplace-wide contact email/address. A seller contact page would be
|
||||
net-new routed content, not an extension of the footer's existing contact
|
||||
surfacing — the footer would, at most, link to it once it exists.
|
||||
|
||||
### SEO — canonical URLs already correct; structured data and sitemap are 100% net-new
|
||||
`services/seo.service.ts` is the sole SEO surface in the app — confirmed no
|
||||
sitemap generator, no robots.txt handler, and no JSON-LD/structured-data
|
||||
code exists anywhere in the repository.
|
||||
|
||||
- **Canonical URLs — Ready today, no change needed.** `siteUrl` is derived
|
||||
from `this.doc?.location?.origin` (actual browser location), not
|
||||
hardcoded — a page served from `seller.market.com` already gets a
|
||||
correct `seller.market.com` canonical URL with zero code changes. This is
|
||||
the one area in the whole audit that needs nothing further.
|
||||
- **Seller branding in meta tags** — `siteName` getter reads
|
||||
`this.uiRuntime.marketplaceDisplayName() || 'Marketplace'`; the
|
||||
`resetToDefaults()` effect reads `bootstrap.seo.default` +
|
||||
`bootstrap.branding` (including `og:image` from
|
||||
`branding.socialImageUrl`/`logoUrl`). **Future:** the same single
|
||||
injection point as Header/Footer above (`UiRuntimeFacade`) — once that
|
||||
facade can prefer seller branding, `SeoService` inherits it automatically
|
||||
without its own changes, since it already reads through the same facade.
|
||||
- **Seller structured data (JSON-LD)** — **does not exist for anything
|
||||
today**, marketplace or seller. This is entirely Future/net-new work, not
|
||||
an extension of an existing pattern.
|
||||
- **Seller sitemap** — no client-side sitemap code exists at all; dynamic
|
||||
sitemap generation is already flagged in `BACKEND.md` as a
|
||||
server-side-only remaining-work item with no frontend action. A
|
||||
per-seller sitemap is the same story: entirely a backend concern.
|
||||
- **Pre-existing, unrelated to seller-scoping, worth flagging anyway**:
|
||||
`og:locale` is hardcoded to `'ru_RU'` in both `setItemMeta()` and
|
||||
`resetToDefaults()` — a real gap for multi-locale SEO generally, not
|
||||
something to fix as part of Seller Management, but adjacent enough to
|
||||
note here since it lives in the same service this audit reviewed closely.
|
||||
|
||||
### Breadcrumbs — no shared component, one call site
|
||||
Already covered under Categories/Search above — repeating for completeness
|
||||
since the mission listed it separately: there is no dedicated breadcrumb
|
||||
component or service anywhere in the storefront. The only breadcrumb logic
|
||||
in the entire codebase is `catalog-container.component.ts`'s local signal,
|
||||
built from `CategoryFacade.getBreadcrumb()`.
|
||||
|
||||
## Summary — future changes only (nothing here is implemented)
|
||||
|
||||
| Area | Current state | Future seller-scoped change |
|
||||
|---|---|---|
|
||||
| Homepage | Ready — thin page-model wrapper | Depends on `WebsiteRuntimeFacade` becoming seller-aware |
|
||||
| Categories / Search | One breadcrumb call site, no seller dimension | Extend `CategoryFacade.getBreadcrumb()` call in `catalog-container.component.ts` |
|
||||
| Products | URLs already dynamic; `setItemMeta()` exists but is dead code | Wire the existing (currently unused) per-page SEO hook before adding seller data to it |
|
||||
| Reviews | Lives inside Product detail, no separate page | No independent seller-scoping work — follows Products |
|
||||
| Favorites | Ready — no branding/URL logic | Optional future filter on already-prepared `Item.sellerId?` |
|
||||
| Cart / Checkout | One inline flow, one popup, no seller/multi-vendor concept | Where Checkout Modes + Unified/Split Orders (both Future in `Seller-Management.md`) would land |
|
||||
| Header | Fully dynamic via `UiRuntimeFacade` | **Highest-leverage single injection point**: prefer `bootstrap.seller?.branding` in `reloadFromBootstrap()` |
|
||||
| Footer | Same facade source as Header | Inherits the Header fix automatically; Seller contact page is net-new routed content |
|
||||
| SEO — canonical URLs | **Already correct**, derived from `location.origin` | None needed |
|
||||
| SEO — branding in meta tags | Reads through `UiRuntimeFacade` | Inherits the Header/Footer fix automatically |
|
||||
| SEO — structured data (JSON-LD) | Does not exist for anything today | 100% net-new, not an extension |
|
||||
| SEO — sitemap | No client-side code at all; backend-only concern (`BACKEND.md`) | No frontend action, ever |
|
||||
| Breadcrumbs | One ad hoc signal, no shared component | Same single call site as Categories/Search |
|
||||
|
||||
## Conclusion
|
||||
|
||||
The storefront's existing discipline — everything reads through
|
||||
`UiRuntimeFacade`/`ConfigService`/bootstrap, nothing hardcodes marketplace
|
||||
identity, canonical URLs derive from actual browser location — means a
|
||||
`seller.market.com` subdomain is **structurally closer to already working
|
||||
than any other part of this audit found**. The entire future-work surface
|
||||
collapses to two real gaps: (1) `UiRuntimeFacade.reloadFromBootstrap()`
|
||||
needs to prefer seller branding when present (one method, cascades to
|
||||
Header/Footer/SEO for free), and (2) Cart/Checkout's single-seller-per-order
|
||||
assumption needs the Unified-vs-Split-Orders decision from
|
||||
`Seller-Management.md` before multi-vendor carts can be handled at all.
|
||||
Structured data and sitemap are not seller-specific gaps — they're simply
|
||||
unbuilt for anyone today.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Seller Management — UX Review
|
||||
|
||||
Review pass over the Phase 1 UI (`admin-seller-management-page.component.*`)
|
||||
against the rest of the Backoffice. Two real issues found and fixed; the
|
||||
rest of the checklist was verified as already consistent because the page
|
||||
is built entirely from existing shared components.
|
||||
|
||||
## Fixed this pass
|
||||
|
||||
1. **Missing label association on the Message field (real a11y bug).**
|
||||
`app-input` self-wires `id`/`aria-describedby` from its injected
|
||||
`FormFieldContext` (confirmed in `input.component.html`); the raw
|
||||
`<textarea>` used for the optional Message field — no dedicated textarea
|
||||
component exists yet anywhere in the app — never received that wiring.
|
||||
The visible label's `for` pointed at an id the textarea never got, so a
|
||||
screen reader wouldn't announce "Message" on focus via the label
|
||||
association (proximity only). Fixed with an explicit `[attr.aria-label]`
|
||||
bound to the same translation key already used for the visible label —
|
||||
correct regardless of the broken `for` linkage.
|
||||
|
||||
2. **Native browser bullets in the Learn More dialog (visual inconsistency).**
|
||||
No global `list-style: none` reset exists for plain `<ul>` anywhere in
|
||||
`src/styles.scss` (only `details > summary` gets one, for the expander
|
||||
chevron). The feature list would have rendered default browser discs —
|
||||
the one place in this page not reusing an existing shared visual
|
||||
language. Replaced with `checkCircle` icon + text rows (`app-icon`,
|
||||
`--success-color` token), consistent with how the rest of the app pairs
|
||||
icons with status/list meaning rather than bare bullets.
|
||||
|
||||
## Verified already consistent (no change needed)
|
||||
|
||||
- **Empty state usage**: every other empty-state consumer in the app
|
||||
(`admin-products-list`, `media-library-page`, `admin-reviews-list`, etc.)
|
||||
uses `app-empty-state` bare — no card wrapper. This page matches that. It
|
||||
is the only one filling the `icon` slot (a subtle primary-tinted circle
|
||||
behind a `store` icon); no other page does this, but this page is also
|
||||
the only one that's *entirely* an empty state as its whole content
|
||||
(every other example sits inside a page that also has a toolbar/table),
|
||||
so a slightly more deliberate visual treatment for the "coming soon"
|
||||
moment is a reasonable, isolated deviation rather than drift.
|
||||
`app-empty-state`'s own description already caps at `max-width: 32rem` —
|
||||
no extra width-constraint code needed.
|
||||
- **Icon reuse**: `store` (empty-state) and `checkCircle` (feature list) —
|
||||
neither icon is reused with a conflicting meaning elsewhere in the app
|
||||
(checked against `icon-registry.ts`'s existing 85-icon map from the prior
|
||||
icon audit).
|
||||
- **Buttons/dialogs/inputs/hover/focus**: 100% shared components
|
||||
(`app-button`, `app-dialog`, `app-input`, `app-form-field`, `app-badge`).
|
||||
Hover, focus-visible, disabled, and loading states are whatever those
|
||||
components already define — verified by inspecting each component's own
|
||||
`.scss`, not re-implemented here. Same reasoning covers contrast (reused
|
||||
tokens, not new color decisions) and dark-theme readiness (every value in
|
||||
this page's own `.scss` is `var(--token, fallback)`, same fallback values
|
||||
already used in `input.component.scss` — nothing hardcoded that a future
|
||||
dark theme couldn't override).
|
||||
- **Dialog accessibility**: `app-dialog` provides `role="dialog"`,
|
||||
`aria-modal="true"`, Tab/Shift+Tab focus trap, Escape-to-close, and
|
||||
focus-restore-on-close — confirmed via the accessibility tree
|
||||
(`role=dialog`, correct `aria-label` matching each dialog's title) and by
|
||||
live-testing focus behavior, not assumed.
|
||||
- **Merchant wording**: every string matches the original brief's exact
|
||||
business-facing copy (Company/Email/Message, "Coming Soon", capability
|
||||
bullets in plain language) — no developer terminology introduced.
|
||||
- **Responsive**: re-verified at 1280px, and at 375px mobile (button rows
|
||||
stack full-width per the existing breakpoint in this page's `.scss`,
|
||||
dialog/list content re-rendered correctly, no layout break).
|
||||
- **Translations**: all new keys (`adminShell.nav.partnersGroup`,
|
||||
`adminShell.nav.sellerManagement`, `adminShell.pages.sellerManagement`,
|
||||
and the full `adminSellerManagement.*` namespace) exist in `en.ts`,
|
||||
`ru.ts`, `hy.ts`, and `translations.ts` (types) — verified by exact-count
|
||||
grep across all three locale files, no hardcoded string found in the
|
||||
component's template or TypeScript.
|
||||
|
||||
## Verification
|
||||
|
||||
`tsc --noEmit` clean. `arch:check` (boundaries + cycles) clean. Live-tested
|
||||
(ru locale, `devBypassAdmin`, desktop 1280px + mobile 375px): Learn More
|
||||
dialog now shows all 6 items with a check icon each (confirmed via DOM
|
||||
query - `svg` present on every `<li>`), Message textarea confirmed to carry
|
||||
`aria-label="Сообщение"`, no console errors at any point.
|
||||
@@ -1,214 +0,0 @@
|
||||
# Seller Management — Capability Documentation
|
||||
|
||||
Status legend used throughout this document:
|
||||
|
||||
- **Implemented** — exists in source on `feature/seller-management-foundation` today, verified (`tsc`, `arch:check`, or live browser test).
|
||||
- **Planned** — has a typed contract or explicit ADR decision, but no code reads/writes it yet.
|
||||
- **Future** — a concept named in this document for roadmap completeness only. No shape, contract, or decision exists yet. Do not build against this section without a new ADR.
|
||||
|
||||
This document is the entry point. Detail lives in its companion docs:
|
||||
[ADR-011](adr/ADR-011-optional-seller-management-module.md) (decision),
|
||||
[Seller-Management-Diagrams.md](Seller-Management-Diagrams.md) (hierarchy/bootstrap-gate diagrams),
|
||||
[Seller-Management-Domain-Models.md](Seller-Management-Domain-Models.md) (every type, field by field),
|
||||
[Seller-Management-UX-Review.md](Seller-Management-UX-Review.md) (Phase 1 UI review).
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Seller Management is an **optional platform capability** that would let one
|
||||
marketplace host multiple independent sellers, each with their own
|
||||
inventory/orders/branding, under one centralized administration. It is not
|
||||
another tenant — a seller is a child scope beneath exactly one marketplace
|
||||
(ADR-011).
|
||||
|
||||
**Implemented today:** typed contracts for the whole hierarchy, a disabled-
|
||||
by-default feature flag, one Backoffice page that explains the capability
|
||||
and collects interest ("Request Access" / "Learn More"). **Nothing else** —
|
||||
no CRUD, no backend, no seller-facing UI, no checkout/order behavior change.
|
||||
|
||||
## 2. Architecture & Hierarchy — Implemented (types only)
|
||||
|
||||
```
|
||||
Platform
|
||||
└── Marketplace (tenant) — always present, backend-resolved (ADR-001)
|
||||
└── Seller (optional) — 0..N per marketplace, backend-resolved (ADR-011)
|
||||
```
|
||||
|
||||
Full diagram set: [Seller-Management-Diagrams.md](Seller-Management-Diagrams.md).
|
||||
|
||||
Rules (ADR-011, enforced by review, not yet by any lint rule):
|
||||
Marketplace is the sole primary tenant. Seller is a child scope, never a
|
||||
sibling tier. The frontend never resolves seller identity itself — same
|
||||
rule as tenant resolution. All seller-aware behavior must check one
|
||||
capability flag, never scattered marketplace/seller conditionals.
|
||||
|
||||
## 3. Marketplace — Implemented (existing, unchanged)
|
||||
|
||||
The marketplace is the existing `TenantConfig`
|
||||
(`shared/models/config/tenant.model.ts`) — resolved by the backend from
|
||||
request Host, exactly as before this work started. Seller Management adds a
|
||||
new `MarketplaceRef` (`core/sellers/models/marketplace-ref.model.ts`): a
|
||||
minimal `{id, slug, name}` view of a marketplace *as seen from a seller
|
||||
record*, not a replacement for `TenantConfig`.
|
||||
|
||||
## 4. Seller — Implemented (types only)
|
||||
|
||||
`Seller` (`core/sellers/models/seller.model.ts`): `id`, `marketplace:
|
||||
MarketplaceRef`, `name`, `slug`, `status: SellerStatus`, optional `branding:
|
||||
SellerBranding`, `createdAt`/`updatedAt`. No repository, gateway, facade, or
|
||||
UI reads or writes this type yet — it exists so future CRUD work has a
|
||||
settled shape instead of inventing one ad hoc.
|
||||
|
||||
**Planned:** a `SellerRepository`/`SellerGateway` pair following the same
|
||||
mock↔API DI-token pattern every other admin domain already uses
|
||||
(`BACKEND.md` §8). **Future:** the actual CRUD screens, list/detail pages,
|
||||
onboarding flow.
|
||||
|
||||
## 5. Roles & Permissions — Implemented (types only)
|
||||
|
||||
`SellerPermissionRole` (`core/sellers/models/seller-permissions.model.ts`):
|
||||
four values — `marketplaceOwner`, `seller`, `sellerStaff`, `platformAdmin`.
|
||||
This is a **separate vocabulary** from the existing `AdminRole` (Owner/
|
||||
Manager/Support/ReadOnly, `core/auth/models/permission.model.ts`) — not
|
||||
merged, not wired into any guard. **No authentication or authorization
|
||||
change exists anywhere in this work.**
|
||||
|
||||
**Planned:** once a real permission model is designed, these roles gate
|
||||
seller-scoped routes/actions the same way `AdminRole` gates admin routes
|
||||
today (ADR-009 capability-guard pattern). **Future:** the actual
|
||||
permission-to-action mapping, custom/finer-grained roles per marketplace.
|
||||
|
||||
## 6. Future Roadmap
|
||||
|
||||
### Feature Flags — Implemented (contract), Planned (real use)
|
||||
|
||||
`BootstrapConfig.modules.sellerManagement.enabled`
|
||||
(`shared/models/config/platform-modules.model.ts`), default `false`
|
||||
(`DEFAULT_PLATFORM_MODULES_CONFIG`). Implemented as a typed contract read by
|
||||
the Phase 1 page (`admin-seller-management-page.component.ts`); no backend
|
||||
sets it to `true` anywhere today, so it is always `false` in practice.
|
||||
|
||||
### Bootstrap — Implemented (contract), Planned (real data)
|
||||
|
||||
`BootstrapConfig.modules?` and `BootstrapConfig.seller?` (`SellerConfig`,
|
||||
`shared/models/config/seller.model.ts`) — both optional, both absent in
|
||||
every real bootstrap response today. When a backend eventually resolves a
|
||||
seller scope, it populates `seller`; until then this field simply doesn't
|
||||
exist on the wire.
|
||||
|
||||
### Future API — Future
|
||||
|
||||
No endpoint exists. When built, it should follow `BACKEND.md`'s existing
|
||||
mock↔API-gateway pattern (§8) rather than a new convention — this is a
|
||||
statement of intent, not a designed contract. No URL, DTO, or status-code
|
||||
behavior is decided.
|
||||
|
||||
### Seller Storefronts — Future
|
||||
|
||||
Concept: a seller-branded storefront view within a marketplace (e.g. a
|
||||
seller's own product listing page reachable from the marketplace). **Not
|
||||
designed.** No route, component, or URL scheme exists or is decided.
|
||||
|
||||
### Seller Branding — Implemented (types only), Future (usage)
|
||||
|
||||
`SellerBranding` (`core/sellers/models/seller-branding.model.ts`): logo,
|
||||
banner, description, contacts, address, theme overrides — every field
|
||||
optional. **Implemented as a type only.** Nothing renders it, nothing falls
|
||||
back from it to marketplace branding — that precedence logic is **Future**
|
||||
work, not yet designed.
|
||||
|
||||
### Seller Ownership — Implemented (schema only), Future (logic)
|
||||
|
||||
`sellerId?: string` added to `Item` (storefront), `AdminProduct`, and
|
||||
`AdminOrder` — optional, absent means marketplace-owned (every existing
|
||||
product/order today). **No code reads or writes this field anywhere.**
|
||||
Ownership rules, transfer, and enforcement are **Future** work.
|
||||
|
||||
### Checkout Modes — Future
|
||||
|
||||
Concept: how checkout behaves when a cart contains items from multiple
|
||||
sellers (e.g. single combined checkout vs. per-seller checkout flows).
|
||||
**Not designed.** No decision exists on this; today every product is
|
||||
marketplace-owned and checkout has exactly one flow, unchanged by this work.
|
||||
|
||||
### Unified Orders / Split Orders — Future
|
||||
|
||||
Concept: whether one customer purchase spanning multiple sellers becomes
|
||||
one order record or splits into one order per seller. **Not designed.**
|
||||
This is a real business decision (payments, refunds, and reporting all
|
||||
depend on the answer) with no default assumed — explicitly listed as an
|
||||
open question for whenever Seller Management moves past preparation.
|
||||
|
||||
## 7. Migration & Compatibility
|
||||
|
||||
### Why existing marketplaces remain unchanged
|
||||
|
||||
- `modules.sellerManagement.enabled` defaults to `false` and no backend
|
||||
sets it — every marketplace today gets identical behavior whether the
|
||||
field is present-and-false or entirely absent from its bootstrap
|
||||
response.
|
||||
- `BootstrapConfig.modules` and `BootstrapConfig.seller` are optional
|
||||
fields; no existing field's type changed.
|
||||
- `sellerId?` on `Item`/`AdminProduct`/`AdminOrder` is optional; no
|
||||
consumer of any of these three types needed updating, verified by
|
||||
`tsc --noEmit` staying clean after each change.
|
||||
- Zero components, facades, services, or routes branch on marketplace or
|
||||
seller identity anywhere in this work (ADR-011 compliance requirement) —
|
||||
there is no conditional to accidentally trigger.
|
||||
- Every commit in this line of work was verified with `tsc --noEmit`,
|
||||
`arch:check` (import boundaries + circular deps), and — for the UI
|
||||
commits — a live browser pass, specifically to confirm no regression to
|
||||
existing pages.
|
||||
|
||||
## 8. Developer Notes
|
||||
|
||||
- All seller domain types live in `core/sellers/models/` (mirrors
|
||||
`core/products/models`, `core/auth/models`). Extend there, not ad hoc in
|
||||
feature folders.
|
||||
- When real seller-aware behavior is eventually built, gate it behind
|
||||
`modules.sellerManagement.enabled` in one place (a capability guard,
|
||||
ADR-009's pattern) — never scattered `if` checks on tenant/seller identity.
|
||||
- The Phase 1 page (`features/admin/seller-management/`) is disposable —
|
||||
it exists to communicate the capability to merchants, not as a
|
||||
foundation to extend. Real seller CRUD UI should be planned fresh once
|
||||
the backend contract exists, not bolted onto this page.
|
||||
|
||||
## 9. Builder Notes
|
||||
|
||||
The Project Editor / Marketplace Builder has **zero seller-awareness**
|
||||
today. Its draft/publish model (`localStorage`-only, no backend write path
|
||||
per `BACKEND.md` §1.10) is entirely marketplace-scoped. If/when a seller
|
||||
needs their own builder-like surface (branding, storefront layout), it must
|
||||
be designed as its own ADR — do not assume the existing builder can be
|
||||
reused as-is for a seller scope without that review, since its facades and
|
||||
schema (ADR-005, ADR-007) were built assuming exactly one config document
|
||||
per marketplace.
|
||||
|
||||
## 10. Backend Notes
|
||||
|
||||
No backend implementation exists for any part of Seller Management. When
|
||||
work begins, follow `BACKEND.md`'s established pattern exactly: a
|
||||
`SellerRepository`/`SellerGateway` behind a DI token, `MockSellerGateway`
|
||||
first, `ApiSellerGateway` swapped in later, same convention every other
|
||||
admin domain in this codebase already uses (`BACKEND.md` §8). The typed
|
||||
models in `core/sellers/models/` are the DTO shapes to implement against —
|
||||
treat them as the contract, not a suggestion to redesign.
|
||||
|
||||
## Diagrams
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["Types & feature flag<br/>(this + prior 3 commits)"] -->|Implemented| B["Phase 1 UI<br/>(Partners > Seller Management page)"]
|
||||
B -->|Implemented| C["Backend contract decisions<br/>(BACKEND.md gaps, own ADR)"]
|
||||
C -->|Future| D["Seller CRUD + real gateway"]
|
||||
D -->|Future| E["Seller Branding rendering<br/>+ Storefronts"]
|
||||
E -->|Future| F["Checkout Modes +<br/>Unified/Split Orders"]
|
||||
|
||||
classDef done fill:#2e7d3222,stroke:#2e7d32,color:inherit;
|
||||
classDef future fill:#6b728022,stroke:#6b7280,color:inherit;
|
||||
class A,B done;
|
||||
class C,D,E,F future;
|
||||
```
|
||||
|
||||
Rollout is strictly left-to-right — no stage after "Phase 1 UI" has started.
|
||||
See [Seller-Management-Diagrams.md](Seller-Management-Diagrams.md) for the
|
||||
hierarchy and bootstrap-gate diagrams (unchanged, still accurate).
|
||||
@@ -1,38 +0,0 @@
|
||||
# Service Standards
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Service Categories
|
||||
|
||||
- Domain Service: business operations and rules.
|
||||
- Integration Service: external API/system communication.
|
||||
- Platform Service: cross-cutting platform concerns.
|
||||
|
||||
## Rules
|
||||
|
||||
- Services must have one clear responsibility.
|
||||
- Services should expose typed contracts only.
|
||||
- Services should avoid UI-specific formatting.
|
||||
- Services should not depend on component classes.
|
||||
- Shared services must not depend on feature modules.
|
||||
- Business decisions must not be driven by `environment.*` flags.
|
||||
- Environment values are limited to infrastructure concerns (API base URLs, provider strategy wiring, auth endpoint origins).
|
||||
|
||||
## Facade Interaction
|
||||
|
||||
- Components call facades.
|
||||
- Facades call services.
|
||||
- Services do not call UI components.
|
||||
|
||||
## Stable Module Protection
|
||||
|
||||
- Existing authentication, payment, authorization services remain behavior-compatible.
|
||||
- Wrap legacy stable behavior with adapters where needed.
|
||||
- No contract changes for auth/payment APIs.
|
||||
|
||||
## Storage and Runtime Access
|
||||
|
||||
- Browser storage access allowed only in approved service boundaries.
|
||||
- Prefer abstraction interfaces for storage access.
|
||||
- Never use storage APIs in UI components.
|
||||
@@ -1,42 +0,0 @@
|
||||
# State Management Standards
|
||||
|
||||
Status: Mandatory
|
||||
Date: 2026-07-03
|
||||
|
||||
## Objectives
|
||||
|
||||
- Keep state predictable, scoped, and replaceable.
|
||||
- Support Website, Builder, and Backoffice without coupling.
|
||||
|
||||
## State Layers
|
||||
|
||||
- Platform State: bootstrap, feature flags, theme, localization, session status.
|
||||
- Domain State: feature-specific bounded context state.
|
||||
- UI State: ephemeral visual state local to component/container.
|
||||
|
||||
## Facade Rules
|
||||
|
||||
- Every domain exposes state through facades.
|
||||
- Facades expose readonly projections/selectors/signals.
|
||||
- Mutations happen through explicit facade commands.
|
||||
|
||||
## Isolation Rules
|
||||
|
||||
- No direct cross-domain state mutation.
|
||||
- No component writes directly into service internals.
|
||||
- Shared state contracts must be explicit and typed.
|
||||
|
||||
## Persistence Rules
|
||||
|
||||
- Persisted state access must be centralized in approved services.
|
||||
- UI components never access localStorage/sessionStorage directly.
|
||||
|
||||
## Feature Flag Interaction
|
||||
|
||||
- State branches for optional capabilities must be capability-driven.
|
||||
- Missing capability paths must return safe defaults.
|
||||
|
||||
## Migration and Compatibility
|
||||
|
||||
- Existing auth/payment behavior remains intact while wrapped by facade boundaries.
|
||||
- Refactoring must preserve observable behavior for critical flows.
|
||||
@@ -1,38 +0,0 @@
|
||||
# ADR-001: Platform Model and Tenancy Strategy
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
The existing repository has evolved from marketplace website delivery.
|
||||
The target product is Marketplace-as-a-Service with unlimited tenants on one runtime.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt a platform runtime model:
|
||||
|
||||
- One Angular application serves all tenants.
|
||||
- Tenant identity is resolved by backend from request Host.
|
||||
- Frontend does not pass tenant id or project key.
|
||||
- Frontend starts by requesting GET /bootstrap.
|
||||
- Tenant-specific website, builder, and backoffice behavior derives from bootstrap configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Tenant onboarding becomes configuration-driven.
|
||||
- Eliminates tenant forks and branch divergence.
|
||||
- Strong separation of platform engine and tenant data.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires strict discipline against tenant conditionals in UI code.
|
||||
- Requires robust bootstrap schema governance.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- No environment-based tenant branching in presentation logic.
|
||||
- No tenant-specific routes hardcoded in feature components.
|
||||
- Tenant behavior is represented in typed configuration contracts.
|
||||
@@ -1,43 +0,0 @@
|
||||
# ADR-002: Layered Feature Architecture with Single Responsibility
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
The platform must support Website, Builder, and Backoffice while keeping shared capabilities reusable and independent.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt the following architecture layers and responsibilities:
|
||||
|
||||
- Core: bootstrap, app wiring, global policies, base adapters.
|
||||
- Shared: pure contracts, pure utilities, generic primitives.
|
||||
- UI Library: reusable presentational components only.
|
||||
- Widgets: configurable functional blocks built from UI components.
|
||||
- Layouts: page section composition and structural orchestration.
|
||||
- Pages: route containers mapping configuration to layouts/widgets.
|
||||
- Website: public commerce experience.
|
||||
- Builder: configuration editing domain.
|
||||
- Backoffice: business data management domain.
|
||||
|
||||
Single responsibility is mandatory for each layer.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Predictable layering and ownership.
|
||||
- Higher reuse across Website, Builder, and Backoffice.
|
||||
- Reduced accidental coupling.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires import boundary enforcement.
|
||||
- Requires upfront contracts before feature implementation.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- Shared and UI layers cannot depend on feature layers.
|
||||
- Feature layers interact through contracts/facades, not direct imports.
|
||||
- New artifacts must be placed in the correct layer folder.
|
||||
@@ -1,39 +0,0 @@
|
||||
# ADR-003: Import Boundaries and Dependency Direction
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
Without strict dependency direction, large Angular codebases accumulate circular dependencies and feature coupling that block reuse.
|
||||
|
||||
## Decision
|
||||
|
||||
Enforce one-way dependency flow:
|
||||
|
||||
Website/Builder/Backoffice -> Pages -> Layouts -> Widgets -> UI Library -> Shared -> Core
|
||||
|
||||
Additional constraints:
|
||||
|
||||
- No circular dependencies.
|
||||
- No feature importing another feature directly.
|
||||
- Core does not depend on any feature.
|
||||
- Shared does not depend on features.
|
||||
- UI Library does not depend on features.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Stable architecture evolution.
|
||||
- Easier testability and extraction.
|
||||
- Faster onboarding with clear module contracts.
|
||||
|
||||
Negative:
|
||||
|
||||
- Some existing direct imports must be replaced by contracts.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- Enforce via lint boundaries and dependency checks.
|
||||
- Violations block merge.
|
||||
@@ -1,11 +0,0 @@
|
||||
# ADR-004: Configuration Bootstrap and Provider Abstraction
|
||||
|
||||
Status: Superseded
|
||||
Date: 2026-07-03
|
||||
Superseded by: `docs/BACKEND_API.md` §4 (Bootstrap) and §14 (Backend replacement pattern)
|
||||
|
||||
## Original decision (preserved for history)
|
||||
|
||||
Configuration must initially come from mock JSON and later from backend API without changing consumers. `ConfigService` is the only configuration entrypoint; consumers depend on typed selectors only; provider implementation is swappable (`MockBootstrapProvider` / `ApiBootstrapProvider`); the frontend calls `GET /bootstrap` when the API provider is enabled and never passes a tenant id.
|
||||
|
||||
This decision remains in effect. The full, verified contract — endpoint, caching, field-by-field DTO reference, and the generalized mock↔API provider-swap pattern this ADR introduced (now used by every admin domain, not just bootstrap) — lives in `docs/BACKEND_API.md`. Read that document for current, code-verified detail; this file is kept only so ADR-numbered references in `docs/architecture/foundation/README.md` continue to resolve.
|
||||
@@ -1,35 +0,0 @@
|
||||
# ADR-005: Dynamic Page, Section, and Widget Rendering
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
Platform websites must be generated from configuration. Hardcoded page composition blocks tenant scalability.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt dynamic rendering engine:
|
||||
|
||||
- A page definition contains ordered sections.
|
||||
- A section contains ordered widgets.
|
||||
- WidgetHost resolves widget type through registry.
|
||||
- Registry-based resolution avoids renderer edits for every new widget.
|
||||
- Hero, Carousel, Header, Footer, and other blocks are widgets/layout entries from configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- New tenant pages created by configuration.
|
||||
- Supports Builder-driven composition.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires robust schema validation.
|
||||
- Requires widget compatibility and versioning discipline.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- Page templates must not hardcode specific widget combinations.
|
||||
- Widget rendering must be data-driven from configuration contracts.
|
||||
@@ -1,42 +0,0 @@
|
||||
# ADR-006: UI Component Purity and Container-Facade Pattern
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
Reusable platform components cannot contain business and integration concerns.
|
||||
|
||||
## Decision
|
||||
|
||||
Separate visual and business responsibilities:
|
||||
|
||||
- UI components are presentational only.
|
||||
- Container components connect facades to UI components.
|
||||
- Facades own orchestration and use services.
|
||||
- Services handle IO and integration.
|
||||
|
||||
UI component restrictions:
|
||||
|
||||
- No HttpClient.
|
||||
- No localStorage/sessionStorage access.
|
||||
- No environment import.
|
||||
- No tenant awareness.
|
||||
- No authentication/payment logic.
|
||||
- No route logic.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Maximum reuse and testability.
|
||||
- Supports widget library portability.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires refactoring of mixed legacy components.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- Components must use Inputs for data and Outputs for events.
|
||||
- Business behavior belongs to facades/containers only.
|
||||
@@ -1,33 +0,0 @@
|
||||
# ADR-007: State Management and Facade Boundaries
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
State must be predictable and isolated by domain to support Website, Builder, and Backoffice without cross-domain leakage.
|
||||
|
||||
## Decision
|
||||
|
||||
Use facade-centered state management by bounded context:
|
||||
|
||||
- Each feature domain exposes one or more facades.
|
||||
- Facades expose read models and command methods.
|
||||
- State is local to domain and projected as readonly selectors/signals.
|
||||
- Shared/global state is limited to platform concerns (configuration, theme, localization, session status).
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Clear ownership of state transitions.
|
||||
- Improved maintainability and testability.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires disciplined facade boundaries.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- Components do not mutate service internals directly.
|
||||
- Cross-domain communication is contract-based, not direct state access.
|
||||
@@ -1,32 +0,0 @@
|
||||
# ADR-008: Theme Engine and Runtime Design Tokens
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
Tenant branding must be configuration-driven and must not require tenant-specific code branches.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce theme engine based on runtime design tokens:
|
||||
|
||||
- Branding, color palette, typography, spacing, icons, logos, favicon derive from configuration.
|
||||
- Theme tokens are applied at runtime through token service and CSS variable mapping.
|
||||
- Feature code consumes semantic tokens, not tenant constants.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Tenant branding changes are configuration-only.
|
||||
- Removes environment-based visual branching.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires token schema governance and fallback policy.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- No tenant-specific style imports in feature components.
|
||||
- UI styling must resolve through semantic token set.
|
||||
@@ -1,32 +0,0 @@
|
||||
# ADR-009: Feature Flags and Capability Guards
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-03
|
||||
|
||||
## Context
|
||||
|
||||
Platform tenants have optional capabilities. Features cannot be assumed always present.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce capability model backed by bootstrap feature flags:
|
||||
|
||||
- FeatureFlagService exposes tenant capabilities.
|
||||
- Routes, widgets, and actions are guarded by capability checks.
|
||||
- Missing capability must degrade gracefully with fallback behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- One runtime supports variable tenant feature sets.
|
||||
- Reduces tenant branching and dead code.
|
||||
|
||||
Negative:
|
||||
|
||||
- Requires explicit defaults and fallback UX.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- Components and pages cannot assume optional feature availability.
|
||||
- Capability checks must be centralized, not scattered conditionals.
|
||||
@@ -1,11 +0,0 @@
|
||||
# ADR-010: Backward Compatibility for Authentication, Payment, and Authorization
|
||||
|
||||
Status: Superseded
|
||||
Date: 2026-07-03
|
||||
Superseded by: `docs/BACKEND_API.md` §2 (Authentication), §2.8 (Payments), §2.5 (admin authorization gap)
|
||||
|
||||
## Original decision (preserved for history)
|
||||
|
||||
Authentication and payment flows are proven and contract-sensitive. Platform refactoring must not break existing integrations. Freeze behavior and contracts for authentication flow, payment API interactions, and authorization logic. Allow only encapsulation and integration-layer isolation, not contract redesign.
|
||||
|
||||
This decision remains in effect. The full, verified contract — the Telegram QR/session flow, cookie policy, the frozen payment endpoint shapes, and the still-unresolved admin-authorization gap this ADR's constraint interacts with — lives in `docs/BACKEND_API.md` §2 and §2.5–§2.8. Read that document for current, code-verified detail; this file is kept only so ADR-numbered references in `docs/architecture/foundation/README.md` continue to resolve.
|
||||
@@ -1,117 +0,0 @@
|
||||
# ADR-011: Optional Seller Management Module
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-07-26
|
||||
|
||||
## Context
|
||||
|
||||
The platform today has a two-level hierarchy: Platform → Marketplace (tenant),
|
||||
per ADR-001. Some marketplaces will eventually need a third, optional level:
|
||||
individual Sellers operating storefronts within one marketplace (a
|
||||
marketplace-of-marketplaces / multi-vendor model). Not every marketplace
|
||||
needs this — most tenants today have none.
|
||||
|
||||
Seller Management must not become a second tenancy model. ADR-001 already
|
||||
established that tenant identity is backend-resolved from request Host and
|
||||
the frontend never passes or resolves tenant identity itself. Introducing
|
||||
sellers must not weaken that discipline or introduce a second, parallel
|
||||
resolution mechanism the frontend has to reason about.
|
||||
|
||||
## Decision
|
||||
|
||||
Seller Management is an **optional platform capability module**, not a new
|
||||
tenancy tier equal to Marketplace:
|
||||
|
||||
```
|
||||
Platform
|
||||
└── Marketplace (tenant) — always present, resolved by backend (ADR-001)
|
||||
└── Seller (optional) — 0..N per marketplace, resolved by backend
|
||||
```
|
||||
|
||||
- **Marketplace remains the sole primary tenant.** A seller is a child scope
|
||||
of exactly one marketplace, never a sibling of Marketplace and never
|
||||
resolved independently of it.
|
||||
- **The frontend never resolves seller identity itself** — same rule as
|
||||
tenant resolution (ADR-001). The backend decides whether the current
|
||||
request scope is marketplace-level or seller-level and reflects that
|
||||
decision in the bootstrap response.
|
||||
- **The frontend consumes bootstrap only.** No new endpoint, header, or
|
||||
client-side resolution logic is introduced by this ADR. If a seller scope
|
||||
applies, `BootstrapConfig.seller` (see `SellerConfig`) is present; if not,
|
||||
it's absent. There is no other channel.
|
||||
- **Gated by a module flag, not scattered conditionals.** The capability is
|
||||
controlled by one typed flag — `BootstrapConfig.modules.sellerManagement.
|
||||
enabled` (see `PlatformModulesConfig`) — checked in one place if/when
|
||||
seller-aware behavior is built, never as ad hoc `if (tenant.id === 'x')`
|
||||
or similar marketplace-specific conditionals anywhere in feature code.
|
||||
This follows the same capability-guard discipline ADR-009 already
|
||||
established for feature flags.
|
||||
|
||||
## Backward Compatibility (non-negotiable)
|
||||
|
||||
- `modules` and `seller` are both optional fields on `BootstrapConfig`.
|
||||
Existing marketplaces whose bootstrap response never includes them are
|
||||
unaffected — untyped-absent is not a special case to handle, it's the
|
||||
default.
|
||||
- `DEFAULT_PLATFORM_MODULES_CONFIG` defaults `sellerManagement.enabled` to
|
||||
`false`. A marketplace that has never heard of this feature, and a
|
||||
marketplace where the backend explicitly disables it, behave identically:
|
||||
no new routes, no new menu entries, no new API calls, no visual change.
|
||||
- No existing `BootstrapConfig` field, route, guard, or component changes as
|
||||
a result of this ADR. This ADR adds types; it changes nothing that already
|
||||
runs.
|
||||
|
||||
## Scope of this ADR
|
||||
|
||||
This ADR and its accompanying typed contracts (`PlatformModulesConfig`,
|
||||
`SellerManagementModuleConfig`, `SellerConfig`) are **architecture only**:
|
||||
|
||||
- No UI is introduced — no seller-facing pages, no admin seller-management
|
||||
screens, no navigation entries.
|
||||
- No backend is implemented — no endpoints, no seller data model, no
|
||||
resolution logic.
|
||||
- No business logic is introduced — no seller CRUD, no seller-scoped
|
||||
permissions, no seller onboarding flow.
|
||||
|
||||
Those are all future work, gated behind `modules.sellerManagement.enabled`,
|
||||
and each will need its own ADR/implementation pass once the module is
|
||||
actually being built out (routing strategy under a seller scope, admin UI,
|
||||
backend data model and resolution, permission model for seller-level roles).
|
||||
This ADR exists so that future work has a typed foundation to build on
|
||||
without retrofitting the platform/marketplace hierarchy after the fact.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Marketplaces that don't need multi-vendor support pay zero cost — no new
|
||||
code path executes, no new field is even present in their bootstrap
|
||||
response.
|
||||
- Future Seller Management work has a settled hierarchy and typed contract
|
||||
to build against instead of ad hoc per-feature decisions about where
|
||||
"seller" fits.
|
||||
- Consistent with the platform's existing capability-guard discipline
|
||||
(ADR-009) — one flag, checked in one place, not scattered conditionals.
|
||||
|
||||
Negative:
|
||||
|
||||
- Adds two optional fields to `BootstrapConfig` that most of the codebase
|
||||
will never populate — acceptable, matches the existing pattern of several
|
||||
other optional bootstrap fields (`header?`, `catalog?`, `layout?`, etc.).
|
||||
- Defers real design decisions (seller-scoped routing, seller admin
|
||||
permissions, seller data ownership) to whenever the module is actually
|
||||
implemented — intentional; this ADR does not pre-invent that design.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- No component, facade, or service may branch on marketplace identity or
|
||||
seller identity directly. All seller-aware behavior, once built, must
|
||||
check `modules.sellerManagement.enabled` (or a capability-guard built on
|
||||
top of it) as the single gate.
|
||||
- No frontend code may attempt to resolve which seller is active by itself
|
||||
(URL parsing, local storage, guessed convention, etc.) — that information
|
||||
only ever comes from `BootstrapConfig.seller`, backend-resolved, exactly
|
||||
like tenant resolution today.
|
||||
- Any future work that adds seller-facing routes, UI, or backend calls must
|
||||
keep all of it inert and unreachable while `modules.sellerManagement.
|
||||
enabled` is `false`, with no exception.
|
||||
@@ -1,640 +0,0 @@
|
||||
> **ARCHIVED 2026-07-26.** Historical sprint log (Sprint 19-28 admin backoffice build-out). Living admin architecture reference now lives in [`docs/BACKEND.md`](../BACKEND.md) (backend contract) and [`docs/ARCHITECTURE.md`](../ARCHITECTURE.md) (frontend architecture). Kept for history only.
|
||||
|
||||
# Marketplace Admin Dashboard - Sprint 19
|
||||
|
||||
## Scope
|
||||
|
||||
Sprint 19 adds the production Admin Dashboard and makes it the default landing
|
||||
page for the admin area. It also wires the previously-unrouted `admin/products`
|
||||
feature and adds route placeholders for backoffice sections that don't have a
|
||||
feature built yet.
|
||||
|
||||
## Routing
|
||||
|
||||
All admin routes live under `/:lang/backoffice/**` (`app.routes.ts`), guarded
|
||||
by the existing `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`):
|
||||
|
||||
```text
|
||||
/:lang/backoffice -> redirects to dashboard
|
||||
/:lang/backoffice/dashboard -> AdminDashboardPageComponent
|
||||
/:lang/backoffice/products -> AdminProductsListPageComponent
|
||||
/:lang/backoffice/products/create -> AdminProductEditorPageComponent
|
||||
/:lang/backoffice/products/:id/edit -> AdminProductEditorPageComponent
|
||||
/:lang/backoffice/products/:id/duplicate -> AdminProductEditorPageComponent
|
||||
/:lang/backoffice/categories -> AdminCategoriesListPageComponent
|
||||
/:lang/backoffice/categories/create -> AdminCategoryEditorPageComponent
|
||||
/:lang/backoffice/categories/:id/edit -> AdminCategoryEditorPageComponent
|
||||
/:lang/backoffice/static-pages -> BackofficeComingSoonPageComponent
|
||||
/:lang/backoffice/transactions -> BackofficeComingSoonPageComponent
|
||||
/:lang/backoffice/orders -> BackofficeComingSoonPageComponent
|
||||
/:lang/backoffice/media -> BackofficeComingSoonPageComponent
|
||||
```
|
||||
|
||||
`admin/products` (`features/admin/products/`) was already fully implemented
|
||||
in an earlier sprint but was never wired into `app.routes.ts` and its internal
|
||||
navigation hardcoded the `ru` locale segment. Both are fixed in this sprint:
|
||||
routes are wired, and `admin-products-list-page.component.ts` /
|
||||
`admin-product-editor-page.component.ts` now build the locale segment from
|
||||
`LanguageService.currentLanguage()`.
|
||||
|
||||
**Dashboard as default admin page:** on successful admin Telegram QR login,
|
||||
`TelegramLoginComponent` (`mode="admin"`) navigates to
|
||||
`/:lang/backoffice/dashboard` (`components/telegram-login/telegram-login.component.ts`).
|
||||
The `backoffice` route's empty path also redirects to `dashboard`, so any bare
|
||||
`/:lang/backoffice` link lands there too.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
src/app/features/admin/dashboard/
|
||||
models/ admin-dashboard.model.ts
|
||||
services/ admin-dashboard-metrics.gateway.interface.ts
|
||||
admin-dashboard-metrics.local.gateway.ts
|
||||
admin-dashboard-metrics-gateway.token.ts
|
||||
admin-dashboard-history.service.ts
|
||||
facade/ admin-dashboard.facade.ts
|
||||
components/ admin-dashboard-card.component.*
|
||||
admin-dashboard-quick-actions.component.*
|
||||
admin-dashboard-activity.component.*
|
||||
admin-dashboard-health.component.*
|
||||
pages/ admin-dashboard-page.component.*
|
||||
|
||||
src/app/features/backoffice/shared/
|
||||
backoffice-coming-soon-page.component.*
|
||||
```
|
||||
|
||||
Follows the existing container/facade/service split (ADR-006, ADR-007):
|
||||
`AdminDashboardPageComponent` is the container, `AdminDashboardFacade` owns
|
||||
orchestration, presentational card/quick-actions/activity/health components
|
||||
take only `@Input()`s and have no HttpClient/localStorage/route access.
|
||||
|
||||
### Data sources (future-ready)
|
||||
|
||||
Cards never read `ConfigService`, `localStorage`, or an HTTP client directly -
|
||||
everything routes through `AdminDashboardFacade`, which composes:
|
||||
|
||||
- **`ProjectEditorFacade`** (already existed) - `bootstrap`, `status`,
|
||||
`lastSavedAt`, `lastPublishedAt` (new, see below), `validationIssues`,
|
||||
`homepageWidgets`. Backs Marketplace Status, Project Name, Current Theme,
|
||||
Languages, Last Publish, Last Draft Save, Bootstrap Version, Active Layout,
|
||||
Enabled Widgets, and the System Health checks.
|
||||
- **`ADMIN_DASHBOARD_METRICS_GATEWAY`** (new `InjectionToken`, same swap
|
||||
pattern as `BACKOFFICE_DATA_PROVIDER`) - defaults to
|
||||
`AdminDashboardMetricsLocalGateway`, which composes
|
||||
`BackofficeDataService.loadCategories()/loadProducts()` (already used by
|
||||
`AdminProductsLocalGateway`) into counts. Backs Categories Count and
|
||||
Products Count. Swapping to a real dashboard-metrics endpoint later means
|
||||
implementing `AdminDashboardMetricsGateway` and rebinding the token - the
|
||||
facade and cards don't change.
|
||||
- **`AdminDashboardHistoryService`** (new) - localStorage-backed activity log,
|
||||
scoped per tenant, same pattern as `ProjectEditorDraftStorageService`. The
|
||||
facade appends an entry whenever `lastSavedAt`/`lastPublishedAt` change
|
||||
(detected via an `effect()`, primed on first read so the initial bootstrap
|
||||
load doesn't get logged as an activity event). Backs Recent Activity.
|
||||
|
||||
### Orders / Revenue
|
||||
|
||||
No backend or local data model exists for orders or revenue anywhere in the
|
||||
codebase (`features/backoffice/orders` is an empty placeholder folder). These
|
||||
two cards render an honest **`pending-backend`** card state ("Awaiting backend
|
||||
integration") rather than fabricated numbers - not a "no data" empty state,
|
||||
since the gap is structural, not a temporarily-empty dataset.
|
||||
|
||||
### Card states
|
||||
|
||||
`AdminDashboardCardComponent` (`components/admin-dashboard-card.component.ts`)
|
||||
renders one of: `loading` (skeleton), `empty`, `error`, `pending-backend`, or
|
||||
the ready value + optional subtitle. The container computes each card's status
|
||||
per data source (bootstrap not yet loaded -> `loading`; metrics gateway error
|
||||
-> `error`; no supported locales -> `empty`; Orders/Revenue -> always
|
||||
`pending-backend`).
|
||||
|
||||
### System Health
|
||||
|
||||
`ProjectValidator` (`features/project-editor/services/project-validator.service.ts`)
|
||||
already covered 5 of the 6 required checks. This sprint added two more:
|
||||
|
||||
- `translationIssues()` - flags a supported non-default locale missing a
|
||||
header nav label translation or a static-page `translations` entry.
|
||||
- `layoutIssues()` - flags `bootstrap.layout.type` or any section's
|
||||
`layout.strategy` that isn't one of the known enum values
|
||||
(`PlatformLayoutType` / `SectionLayoutStrategy`). Runtime validation matters
|
||||
here because bootstrap JSON isn't type-checked at load time.
|
||||
|
||||
Dashboard mapping (`AdminDashboardFacade.healthChecks`):
|
||||
|
||||
| Dashboard label | Validator code |
|
||||
|---|---|
|
||||
| Bootstrap valid | structural: `bootstrap !== null && schemaVersion` set |
|
||||
| Configuration valid | no validation issues at all |
|
||||
| Missing translations | `missing-translations` (new) |
|
||||
| Invalid colors | `invalid-colors` (existing) |
|
||||
| Invalid widget references | `missing-widget` (existing - a homepage widget with no `type`) |
|
||||
| Invalid layouts | `invalid-layouts` (new) |
|
||||
|
||||
### Quick Actions
|
||||
|
||||
Static list in `AdminDashboardFacade` (`route` arrays relative to the lang
|
||||
root); the page component prefixes the current locale
|
||||
(`LanguageService.currentLanguage()`) before binding `routerLink`. Categories,
|
||||
Static Pages, Transactions, Orders, and Media Library currently land on
|
||||
`BackofficeComingSoonPageComponent` since those features aren't built yet -
|
||||
this is a routing placeholder, not a dashboard card placeholder.
|
||||
|
||||
### `lastPublishedAt` (ProjectEditorFacade change)
|
||||
|
||||
Before this sprint, `publish()` only updated `lastSavedAt`, so "last draft
|
||||
save" and "last publish" were indistinguishable after a publish. Added
|
||||
`lastPublishedAt: number | null` to `ProjectEditorState` /
|
||||
`ProjectEditorFacade`, set only inside `publish()`. `lastSavedAt` behavior is
|
||||
unchanged (still updated by both `save()` and `publish()`).
|
||||
|
||||
## Sprint 20 - Category Management
|
||||
|
||||
`features/admin/categories/` (model/gateway/facade/pages/components), same
|
||||
container/facade/service split as `admin/products` and `admin/dashboard`:
|
||||
|
||||
```text
|
||||
src/app/features/admin/categories/
|
||||
models/ admin-category.model.ts
|
||||
services/ admin-categories-gateway.interface.ts
|
||||
admin-categories-local.gateway.ts
|
||||
admin-categories-form.factory.ts
|
||||
facade/ admin-categories.facade.ts
|
||||
guards/ admin-category-dirty.guard.ts
|
||||
components/ admin-categories-list.component.*
|
||||
admin-category-form.component.*
|
||||
pages/ admin-categories-list-page.component.ts
|
||||
admin-category-editor-page.component.ts
|
||||
```
|
||||
|
||||
- **Hierarchy**: `AdminCategory.parentId` (nullable). List page renders a
|
||||
flattened, indented tree (`AdminCategoriesFacade.rootCategories()` /
|
||||
`childrenOf(id)`); the editor's parent `<select>` excludes the category
|
||||
itself and its descendants to prevent cycles.
|
||||
- **Reordering**: native HTML5 drag-and-drop in
|
||||
`admin-categories-list.component.ts` (`draggable`, `dragstart`/`drop`),
|
||||
persists via `AdminCategoriesFacade.reorder()` which just rewrites `order`.
|
||||
- **Delete/restore**: soft delete (`deletedAt` timestamp). Blocked
|
||||
client-side (`facade.canDelete()`) if the category has children or
|
||||
`itemsCount > 0`; list has an "include deleted" filter with a Restore
|
||||
action for soft-deleted rows.
|
||||
- **Draft/publish**: `status: 'draft' | 'published'`, set by the editor's
|
||||
"Save Draft" vs "Publish" buttons (`AdminCategoriesFacade.saveDraft(publish)`).
|
||||
- **Local draft recovery + unsaved-changes guard**: every `updateDraft()`
|
||||
call persists the in-progress category to `localStorage` under
|
||||
`admin-category-draft:<id>` (via the existing `LocalStorageService`,
|
||||
same pattern as Project Editor autosave); the editor reloads that draft
|
||||
ahead of the saved value if present, and is cleared on save.
|
||||
`adminCategoryDirtyGuard` (mirrors `projectEditorDirtyGuard`) blocks
|
||||
navigation away from an unsaved edit with `window.confirm`.
|
||||
- **Image**: reuses the existing `MediaPickerComponent` (same one used by
|
||||
Media Manager) rather than a free-text URL field.
|
||||
- **Seed data**: `AdminCategoriesLocalGateway` seeds its in-memory cache from
|
||||
`BackofficeDataService.loadCategories()` (`CategoryCardConfig`, currently
|
||||
flat/no hierarchy) - same swappable-provider pattern as
|
||||
`AdminProductsLocalGateway`.
|
||||
- **Not yet wired**: `admin/products`' category `<select>` still uses
|
||||
`AdminProductsGateway.loadCategories()` (its own `AdminProductCategoryOption`
|
||||
seed), not `AdminCategoriesGateway` - unifying them is Sprint 21 scope
|
||||
(`docs/SPRINT-PLAN.md`).
|
||||
|
||||
## Sprint 21 - Product Management completion
|
||||
|
||||
- **Categories now real**: `AdminProductsLocalGateway` seeds its category dropdown from `AdminCategoriesLocalGateway.loadCategories()` (Sprint 20) instead of raw `BackofficeDataService.loadCategories()` - product `categoryId` now points at real admin-managed categories.
|
||||
- **Archive/restore**: `AdminProduct.archived` (soft, distinct from `visible`). List has an "include archived" filter + per-row Archive/Restore action; archived products excluded by default (mirrors categories' `deletedAt`/restore pattern).
|
||||
- **Barcode**: added alongside `sku`.
|
||||
- **Variants**: lightweight `AdminProductVariant[]` (`name`/`price`/`quantity`), edited as `name|price|quantity` lines (same textarea-parse convention as `specifications`/`attributes`). Not a full options-matrix variant system - scoped to what the model/backend contract actually needs today.
|
||||
- **Related products**: `relatedProductIds: string[]`, checkbox picker in the editor sourced from `AdminProductFormComponent`'s `allProducts` input - which is `AdminProductsFacade.products()`, i.e. whatever page is currently loaded in the facade (usually primed by navigating from the list). Not a full catalog search; fine for the current mock-data scale, worth revisiting if `AdminProductsLocalGateway` is ever swapped for a real API with more than a page of products.
|
||||
- **Gallery**: `media.gallery` now built via the shared `MediaPickerComponent` (add/remove thumbnails) instead of a raw URL textarea; `media.images`/`media.videos` unchanged (still textarea, out of this ticket's scope).
|
||||
- **Preview**: simple read-only line in the editor showing computed discounted price.
|
||||
- **Infinite scroll**: `AdminProductsFacade.infiniteScroll` toggle - when on, `loadMore()` appends the next page to `products()` instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged).
|
||||
|
||||
## Sprint 22 - Media System hardening
|
||||
|
||||
`core/media/` (`MediaRepository` abstraction, `MockMediaRepository` IndexedDB
|
||||
implementation) + `features/backoffice/media/` + the shared
|
||||
`shared/media/media-picker/`:
|
||||
|
||||
- **Folders**: flat `folder?: string` tag on `MediaAsset` (no nesting) -
|
||||
"New folder" just sets the active filter to a name typed via
|
||||
`window.prompt` (mirrors the `window.confirm` pattern already used for
|
||||
destructive actions elsewhere); the folder is created implicitly the next
|
||||
time something uploads into it. `MediaRepository.listFolders()` derives
|
||||
the folder list from existing records rather than a separate folder
|
||||
entity - intentionally light, matches the flat-storage reality of an
|
||||
IndexedDB mock.
|
||||
- **Tags**: already existed on `MediaAsset`; added an edit affordance
|
||||
(`window.prompt`, comma-separated) and `MediaLibraryFacade.updateTags()`.
|
||||
- **Validation**: `MockMediaRepository.validateFile()` rejects anything over
|
||||
10MB or outside the allow-list (`jpeg/png/webp/gif/svg+xml/pdf`); errors
|
||||
now propagate as real messages through `MediaLibraryFacade.error` (both
|
||||
`media-library-page` and `media-picker` display it - previously upload
|
||||
failures were swallowed into a generic string).
|
||||
- **SVG sanitization**: `sanitizeSvg()` strips `<script>` tags and
|
||||
`on*="..."` attributes from uploaded SVG markup before storing it, since
|
||||
SVG is the one accepted format that can carry inline script.
|
||||
- **Compression/resize**: raster images (not SVG/GIF) are downscaled to a
|
||||
2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via
|
||||
`<canvas>` before being stored - client-side only, no crop UI. A full
|
||||
interactive cropper was out of scope for this ticket; revisit if a real
|
||||
design need for manual cropping shows up.
|
||||
- **Reuse confirmed**: `MediaPickerComponent` is now wired into Category
|
||||
images (Sprint 20), Product gallery (Sprint 21), and Project Editor
|
||||
branding (logo / compact logo / favicon, this sprint) - one media library
|
||||
for the whole platform, per the sprint goal. Static Pages editor has no
|
||||
image fields to wire (confirmed, not a gap). Hero image: no dedicated
|
||||
hero-image field exists in `BootstrapConfig` today - nothing to wire.
|
||||
- **Storage abstraction**: already existed via `MediaRepository` (abstract
|
||||
class + DI token `providedIn: 'root'` on `MockMediaRepository`) - swapping
|
||||
to a real CDN/backend means implementing `MediaRepository` against a real
|
||||
API and rebinding the provider; no consumer (`MediaLibraryFacade`,
|
||||
`MediaPickerComponent`, or any of the pickers above) changes.
|
||||
|
||||
## Sprint 22 - Media System hardening
|
||||
|
||||
`core/media/` (`MediaRepository` abstraction, `MockMediaRepository` IndexedDB
|
||||
implementation) + `features/backoffice/media/` + the shared
|
||||
`shared/media/media-picker/`:
|
||||
|
||||
- **Folders**: flat `folder?: string` tag on `MediaAsset` (no nesting) -
|
||||
"New folder" just sets the active filter to a name typed via
|
||||
`window.prompt` (mirrors the `window.confirm` pattern already used for
|
||||
destructive actions elsewhere); the folder is created implicitly the next
|
||||
time something uploads into it. `MediaRepository.listFolders()` derives
|
||||
the folder list from existing records rather than a separate folder
|
||||
entity - intentionally light, matches the flat-storage reality of an
|
||||
IndexedDB mock.
|
||||
- **Tags**: already existed on `MediaAsset`; added an edit affordance
|
||||
(`window.prompt`, comma-separated) and `MediaLibraryFacade.updateTags()`.
|
||||
- **Validation**: `MockMediaRepository.validateFile()` rejects anything over
|
||||
10MB or outside the allow-list (`jpeg/png/webp/gif/svg+xml/pdf`); errors
|
||||
now propagate as real messages through `MediaLibraryFacade.error` (both
|
||||
`media-library-page` and `media-picker` display it - previously upload
|
||||
failures were swallowed into a generic string).
|
||||
- **SVG sanitization**: `sanitizeSvg()` strips `<script>` tags and
|
||||
`on*="..."` attributes from uploaded SVG markup before storing it, since
|
||||
SVG is the one accepted format that can carry inline script.
|
||||
- **Compression/resize**: raster images (not SVG/GIF) are downscaled to a
|
||||
2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via
|
||||
`<canvas>` before being stored - client-side only, no crop UI. A full
|
||||
interactive cropper was out of scope for this ticket; revisit if a real
|
||||
design need for manual cropping shows up.
|
||||
- **Reuse confirmed**: `MediaPickerComponent` is now wired into Category
|
||||
images (Sprint 20), Product gallery (Sprint 21), and Project Editor
|
||||
branding (logo / compact logo / favicon, this sprint) - one media library
|
||||
for the whole platform, per the sprint goal. Static Pages editor has no
|
||||
image fields to wire (confirmed, not a gap). Hero image: no dedicated
|
||||
hero-image field exists in `BootstrapConfig` today ('hero' only appears
|
||||
as a `SectionLayoutStrategy` enum value) - nothing to wire.
|
||||
- **Storage abstraction**: already existed via `MediaRepository` (abstract
|
||||
class + DI token `providedIn: 'root'` on `MockMediaRepository`) - swapping
|
||||
to a real CDN/backend means implementing `MediaRepository` against a real
|
||||
API and rebinding the provider; no consumer (`MediaLibraryFacade`,
|
||||
`MediaPickerComponent`, or any of the pickers above) changes.
|
||||
|
||||
## Sprint 23 - Orders (mock/local)
|
||||
|
||||
`features/admin/orders/` (model/gateway/facade/pages), same
|
||||
container/facade/service split as the rest of `admin/*`:
|
||||
|
||||
- **No real data source exists for orders anywhere in this repo** (already
|
||||
called out in Sprint 19's dashboard gap and `docs/BACKEND_API.md#611-backoffice--orders-planned`) -
|
||||
`AdminOrdersLocalGateway` seeds 24 deterministic synthetic orders in
|
||||
memory (cycling through all statuses/customers) rather than reading from
|
||||
`BackofficeDataService`, since there is nothing there to read. This is
|
||||
explicitly a placeholder to unblock the admin UI, not a real mock of
|
||||
production order volume.
|
||||
- List: search (order number/customer/email), status filter, pagination,
|
||||
CSV export (client-side `Blob` download, no server round-trip).
|
||||
- Detail: customer/payment/shipping info, itemized line items + total,
|
||||
status timeline, change-status dropdown, refund request and cancel
|
||||
(both `window.confirm`-gated), customer-visible notes vs internal-only
|
||||
notes (two separate free-text logs), print invoice via `window.print()`
|
||||
with a `@media print` rule hiding all non-invoice chrome (`.no-print`) -
|
||||
no PDF generation library, deliberately minimal.
|
||||
- Wired into `/:lang/backoffice/orders` and `/:lang/backoffice/orders/:id`,
|
||||
replacing the coming-soon placeholder.
|
||||
|
||||
## Sprint 24 - Transactions (mock/local)
|
||||
|
||||
`features/admin/transactions/`. `AdminTransactionsLocalGateway` derives its
|
||||
mock data from `AdminOrdersLocalGateway`'s 24 seeded orders (one
|
||||
transaction per order, deterministic type/status/method assignment) rather
|
||||
than a separate synthetic dataset - keeps order numbers/totals consistent
|
||||
between the two mock feature areas.
|
||||
|
||||
- List: search, status filter, type filter (payment/refund/qr_payment),
|
||||
pagination, CSV export.
|
||||
- Retry failed transactions (`status: 'failed' -> 'retried'`, appends an
|
||||
audit entry).
|
||||
- Fraud flag toggle per transaction.
|
||||
- Audit log: each transaction carries its own `audit: AdminTransactionAuditEntry[]`
|
||||
(creation, retries, fraud-flag changes), viewed via a dialog - this is a
|
||||
per-transaction audit trail, not the system-wide audit/security log
|
||||
planned for Sprint 26 (Monitoring); the two are intentionally separate
|
||||
scopes.
|
||||
- Wired into `/:lang/backoffice/transactions`, replacing the coming-soon
|
||||
placeholder.
|
||||
|
||||
## Sprint 25 - Users & Roles (mock/local)
|
||||
|
||||
`features/admin/users/`. Single consolidated page (`admin-users-page`) at
|
||||
`/:lang/backoffice/users` - not previously in the Quick Actions list or
|
||||
routes at all, this is a net-new admin section.
|
||||
|
||||
- **Users**: name, Telegram username, `scope` (`marketplace` vs `office`
|
||||
admin - distinguishes tenant-level owners/admins from internal staff),
|
||||
role, status (`active`/`invited`/`suspended`), last login. Role change is
|
||||
an inline `<select>`; suspend/reactivate is confirm-gated for suspend
|
||||
only.
|
||||
- **Roles/permissions**: 4 built-in roles (`owner`/`admin`/`editor`/`viewer`)
|
||||
with a flat permission-string list (`products.manage`, `*` for owner,
|
||||
etc.) - a real permission catalog and custom-role creation don't exist,
|
||||
intentionally scoped down to what's needed to demonstrate the model.
|
||||
- **Invitations**: email + role + scope form, pending list with revoke.
|
||||
No email actually sends - `AdminUsersLocalGateway.inviteUser()` only
|
||||
creates the local record.
|
||||
- **Passwordless login**: already existed before this sprint -
|
||||
`AdminAuthService`'s Telegram QR flow (`docs/ADMIN.md`'s existing admin
|
||||
login section, `docs/BACKEND_API.md#25-the-admin-authorization-gap-critical--security-relevant-unresolved`). This sprint's Users page links
|
||||
to it via a hint, doesn't reimplement it.
|
||||
- **Session manager / device manager**: per-user session list (device, IP,
|
||||
last active, current-session badge) with per-session revoke, mocked
|
||||
(`AdminUsersLocalGateway.loadSessions()` fabricates 2 sessions per user
|
||||
on first view) - the real `AdminAuthService`/session-cookie flow only
|
||||
ever tracks the *current* browser's session, so multi-device session
|
||||
listing has no real backend counterpart yet (see `docs/BACKEND_API.md#25-the-admin-authorization-gap-critical--security-relevant-unresolved`).
|
||||
- **Audit**: per-user audit log (role/status changes), same dialog pattern
|
||||
as Sprint 24's per-transaction audit - not the system-wide security/audit
|
||||
log planned for Sprint 26.
|
||||
- Wired into `AdminDashboardFacade`'s Quick Actions list (`dashboard.actionUsers`
|
||||
-> `/:lang/backoffice/users`).
|
||||
|
||||
## Sprint 26 - Monitoring (mock/local, health reuses real data)
|
||||
|
||||
`features/admin/monitoring/`, single page at `/:lang/backoffice/monitoring`
|
||||
(new Dashboard Quick Action).
|
||||
|
||||
- **Health**: reuses `AdminDashboardFacade.healthChecks` directly (the same
|
||||
real, non-mocked bootstrap-validation checks from Sprint 19's dashboard)
|
||||
instead of duplicating the logic - this is the one section on this page
|
||||
backed by real data.
|
||||
- **Audit / security / login / failed-login / API / error / warning
|
||||
events**: one unified `AdminMonitoringEvent` feed (`category` + `level`
|
||||
discriminators) with category filter + search, seeded with 40
|
||||
deterministic synthetic entries by `AdminMonitoringLocalGateway` - no
|
||||
logging backend exists anywhere in this system, so there is nothing real
|
||||
to read from.
|
||||
- **Queue monitoring**: 3 mock named queues with depth + status.
|
||||
- **Webhook monitoring**: mock delivery log (endpoint/event/status/time).
|
||||
- This is deliberately a separate, system-wide log from the two
|
||||
narrower-scoped audit trails added earlier: Sprint 24's per-transaction
|
||||
audit and Sprint 25's per-user audit. No consolidation attempted - they
|
||||
track different things.
|
||||
|
||||
## Sprint 27 - Analytics
|
||||
|
||||
`features/admin/analytics/`, net-new `/:lang/backoffice/analytics` route
|
||||
+ Dashboard Quick Action.
|
||||
|
||||
- **Real, derived data**: revenue/orders/avg-order-value/sales-over-time
|
||||
chart/top-products are computed by composing the existing
|
||||
`AdminOrdersLocalGateway` (Sprint 23's seeded mock orders) - not a
|
||||
separate fabricated dataset. Products/Categories counts come from
|
||||
`AdminProductsLocalGateway`/`AdminCategoriesLocalGateway`. All of this is
|
||||
still ultimately backed by mock order/product/category data (per those
|
||||
sprints), but the *aggregation* is real arithmetic over that data, not
|
||||
invented numbers.
|
||||
- **Visitors, funnels, heatmaps**: no analytics/tracking pipeline exists
|
||||
anywhere in this codebase, so these render an explicit
|
||||
"Awaiting backend integration" (`pending-backend`) badge, same convention
|
||||
as the Sprint 19 dashboard's Orders/Revenue cards before Sprint 23 -
|
||||
not fabricated numbers, not a generic empty state.
|
||||
- **Chart**: a plain inline `<div>`-bar chart driven by `[style.height.%]`,
|
||||
no charting library pulled in - reasonable for one sales-over-time series
|
||||
at this scale; revisit if more chart types are actually needed.
|
||||
- **Date ranges**: 7/30/90-day toggle filters orders by `createdAt`.
|
||||
- **Export**: CSV of the sales series (client-side `Blob` download, same
|
||||
pattern as Orders/Transactions).
|
||||
|
||||
## Sprint 28 - Marketplace Polish
|
||||
|
||||
Full scope per `docs/SPRINT-PLAN.md`: Lighthouse/a11y sweep, animations,
|
||||
skeleton/empty/error state consistency, responsive fixes, SEO/meta/social
|
||||
preview/robots/sitemap. Landed across two commits in the same session (an
|
||||
earlier, narrower "admin/*-only" pass, then this session's follow-up
|
||||
completing the rest of the brief) - this section describes the combined,
|
||||
final result, not just the later commit.
|
||||
|
||||
- **Design-system consistency (skeleton/empty states)**: audited every
|
||||
admin section built in Sprints 20-27 against the shared `app-skeleton` /
|
||||
`app-empty-state` primitives (`shared/ui/skeleton`, `shared/ui/empty-state`,
|
||||
see their own "add reusable ... primitive" commits). Before this sprint,
|
||||
`admin/products`, `admin/users`, `admin/monitoring`, and `admin/analytics`
|
||||
had a `loading` facade signal that was never read in the template (blank
|
||||
table during fetch, no empty-state fallback); `admin/categories`,
|
||||
`admin/orders`, `admin/transactions`, and the media library already had
|
||||
`app-empty-state` but no loading skeleton; `admin/dashboard`'s card
|
||||
component used a hand-rolled shimmer `<div>` + ad-hoc `<p>` text that
|
||||
pre-dated the shared primitives. Fixed: all eight now show `app-skeleton`
|
||||
rows/cards while `loading()` is true, then either `app-empty-state` (new
|
||||
`adminProducts.emptyTitle`/`adminUsers.emptyTitle`/
|
||||
`adminMonitoring.eventsEmptyTitle`/`adminAnalytics.topProductsEmptyTitle`
|
||||
+ description keys added to `translations.ts`/`en.ts`/`ru.ts`/`hy.ts`) or
|
||||
the populated table. `admin-dashboard-card.component.html`'s loading case
|
||||
now renders `<app-skeleton shape="rect" height="24px" width="60%" />`
|
||||
instead of its own shimmer CSS (removed the now-dead
|
||||
`dashboard-card__skeleton` rule + keyframes). Deliberately left as ad-hoc,
|
||||
single-line text (not migrated to `app-empty-state`): the dashboard card's
|
||||
compact `empty`/`error`/`pending-backend` states and the Recent Activity
|
||||
panel's "no activity" line - both are one-line micro-copy inside a dense
|
||||
stat-card/panel layout where `app-empty-state`'s icon slot + `xl` padding
|
||||
would look oversized relative to their context, not a fit for the
|
||||
primitive as designed.
|
||||
- **Accessibility**: every bare `<select>` across `admin/categories`,
|
||||
`admin/products`, `admin/orders`, `admin/transactions`, `admin/users`,
|
||||
and `admin/monitoring` that wasn't already inside a `<label>` (which
|
||||
provides implicit association) now has an explicit `aria-label`. Selects
|
||||
already nested in `<label>` (e.g. product form's category/stock-status
|
||||
selects, category form's parent select) were left as-is - already
|
||||
correct. Manual audit otherwise: `DialogComponent` (`shared/ui/dialog/`)
|
||||
already had a real focus trap, Escape-to-close, `aria-modal`, and
|
||||
`aria-label` from an earlier sprint - no changes needed. Every `<img>` in
|
||||
`src/app/**` was checked for missing `alt` (grepped for `<img` without an
|
||||
`alt`/`[alt]`/`[attr.alt]` binding) - none found; all images already have
|
||||
real or bound alt text.
|
||||
- **Animations**: added a global `prefers-reduced-motion: reduce` override
|
||||
in `src/styles.scss` that neutralizes animation/transition durations and
|
||||
smooth-scroll everywhere, so the many existing hover transforms
|
||||
(`.card:hover`, `.btn:hover`, `.product-card:hover`), the `.section`
|
||||
fade-in, and every skeleton shimmer respect the OS accessibility setting
|
||||
in one place, rather than requiring each component to opt in individually
|
||||
(a few, like `shared/ui/skeleton`, already had their own local override).
|
||||
- **SEO**: `SeoService.resetToDefaults()` (`src/app/services/seo.service.ts`)
|
||||
previously hardcoded the site-wide `<title>`/description/OG/Twitter
|
||||
defaults (including a reference to a nonexistent `/og-image.jpg`)
|
||||
regardless of tenant. It now reads the real `bootstrap.seo.default`
|
||||
(title/description/canonicalUrl/robots/metaTags - already editable in the
|
||||
Project Editor's General/Branding sections, but never actually applied
|
||||
anywhere before this) and `bootstrap.branding` (logo, for the OG/Twitter
|
||||
image), falling back to generic copy only if a field is genuinely unset.
|
||||
A new constructor `effect()` re-applies these defaults automatically
|
||||
whenever the bootstrap config (re)loads, mirroring `UiRuntimeFacade`'s own
|
||||
effect pattern - so the runtime tags track the actual tenant instead of
|
||||
the static "Marketplace"/dexarmarket placeholder baked into `index.html`
|
||||
(which remains as the pre-JS/no-JS-crawler fallback only, unavoidable
|
||||
without SSR).
|
||||
- **Sitemap/robots**: added `public/sitemap.xml` (new) with the statically-
|
||||
known top-level marketplace routes (home/catalog/search/wishlist/compare)
|
||||
for the default `ru` locale segment, referenced from a new `Sitemap:`
|
||||
directive in `public/robots.txt` (which also now blocks
|
||||
`/*/backoffice`, `/*/edit`, `/*/project-editor`, and `/__diagnostics`
|
||||
from crawling). Documented limitation (not faked): this is a config-driven,
|
||||
multi-tenant platform - locales/categories/products/static pages are only
|
||||
known at runtime per tenant, not enumerable client-side at build time. A
|
||||
real per-tenant sitemap needs a backend/build-time generator - see
|
||||
`docs/BACKEND_API.md#619-sitemap-future--static-baseline-only-today`.
|
||||
- **Responsive**: spot-checked the admin backoffice and customer-facing
|
||||
marketplace at mobile/tablet/desktop widths. `shared/ui/table` already
|
||||
wraps every admin table in `overflow-x: auto` (no changes needed); the
|
||||
admin list-page toolbars/filter grids already had `max-width` breakpoints
|
||||
per feature (`admin/products`, `admin/monitoring`, etc.) - added the same
|
||||
`.skeleton-rows` grid class alongside those existing breakpoints rather
|
||||
than introducing a new layout system.
|
||||
- **Lighthouse**: no live browser/Lighthouse run in this environment (same
|
||||
constraint noted in every prior sprint's admin verification - the guarded
|
||||
admin route is blocked from live click-through here); the SEO/a11y/
|
||||
animation items above are the manual-audit equivalent of what a
|
||||
Lighthouse pass would flag (missing meta tags, missing alt text, motion
|
||||
without a reduced-motion fallback, missing loading feedback).
|
||||
- **Bundle size**: the `700 kB` initial-bundle budget warning (~198 kB over,
|
||||
configured in `angular.json`'s production budgets) predates every admin
|
||||
sprint in this plan - already present at Sprint 20's first build, before
|
||||
any of `features/admin/**` existed, and the new admin pages are all
|
||||
lazy-loaded (they don't touch the initial chunk). Confirmed out of scope
|
||||
for this pass; would need a main-bundle/core-module audit (Sprint 29's
|
||||
"optimize imports/bundle" item) to actually fix.
|
||||
- **Found but deferred to Sprint 29** (see `docs/KNOWN-ISSUES.md`): almost
|
||||
every string across `admin/products`/`admin/categories`/`admin/orders`/
|
||||
`admin/transactions`/`admin/users`/`admin/monitoring`/`admin/analytics`
|
||||
(~178 distinct `adminXxx.*` translate-pipe keys) has no corresponding
|
||||
entry in `translations.ts`/`en.ts`/`ru.ts`/`hy.ts` and renders as a raw
|
||||
key string - the same bug class as the dashboard Quick Actions fix in
|
||||
`1db63ac`, at much larger scale. Sprint 28 only adds the small number of
|
||||
new keys its own empty-state work introduces (see above); authoring the
|
||||
full ~178-key backfill is Sprint 29's explicit "translation validation"
|
||||
scope, not squeezed into this polish pass.
|
||||
|
||||
## Bug-hunt audit pass (2026-07-17)
|
||||
|
||||
Same method as the project-editor audit (`docs/EDITOR.md`'s "Bug-hunt audit
|
||||
pass" section): read `admin/products` and `admin/categories` end to end
|
||||
(facades, gateways, form factories, guards, presentational components), find
|
||||
real reproducible bugs (not cosmetic nitpicks), reproduce each live via
|
||||
`window.ng.getComponent()` on `/:lang/backoffice/{products,categories}/...
|
||||
?devBypassAdmin=true` before fixing, re-verify after. The real backend is
|
||||
unreachable in this environment (same constraint as every prior admin
|
||||
sprint's verification note) - `AdminCategoriesLocalGateway`/
|
||||
`AdminProductsLocalGateway` sit behind `BackofficeDataService`, which itself
|
||||
calls out to an HTTP provider that 404s here, so both facades' `loadList()`
|
||||
error handlers reset to an empty array. Where the list couldn't populate via
|
||||
the real click-through, bugs were reproduced by seeding
|
||||
`facade.categories.set([...])`/`facade.products.set([...])` directly with
|
||||
synthetic rows and driving the exact same facade methods the UI calls - the
|
||||
gateway calls captured are identical either way, since the facade doesn't
|
||||
branch on how its signals got populated.
|
||||
|
||||
Found 2 real bugs in `admin/categories`, both fixed:
|
||||
|
||||
- **Create-category draft recovery was permanently dead + leaked
|
||||
`localStorage` forever.** `AdminCategoriesFacade.startCreate()` generated
|
||||
the draft's id via `category-${Date.now()}` and read/wrote its autosave
|
||||
entry under `admin-category-draft:<that id>`. Since the id is different
|
||||
every single call, a draft written during one "create category" visit can
|
||||
never be found by a later `startCreate()` call (even seconds later, same
|
||||
tab) - the recovery feature the sprint 20 changelog describes ("the editor
|
||||
reloads that draft ahead of the saved value if present") never actually
|
||||
triggered for new categories, only for edits (stable real `id`). Every
|
||||
abandoned create attempt also left an orphaned, never-cleaned
|
||||
`localStorage` entry. Fixed by tracking a `draftStorageKey` field on the
|
||||
facade, set to a fixed `admin-category-draft:new` key in create mode
|
||||
(stable across calls) and to `admin-category-draft:<id>` in edit mode
|
||||
(unchanged, already correct); `saveDraft()`/`discardDraftRecovery()` clear
|
||||
whichever key is current instead of re-deriving it from the (possibly
|
||||
stale) draft id.
|
||||
- Verified live: `updateDraft({title})` -> localStorage key
|
||||
`admin-category-draft:category-<ts1>`; calling `startCreate()` again
|
||||
(simulating navigate-away/back) generated `category-<ts2>` and recovered
|
||||
nothing (`title` reset to `''`, `dirty=false`, old key orphaned). After
|
||||
the fix, the same sequence recovers the title/dirty state correctly under
|
||||
the stable key, and `saveDraft()` clears it.
|
||||
- **Drag-and-drop category reordering silently did nothing (or moved items
|
||||
to the wrong spot) because it wrote duplicate `order` values instead of
|
||||
repositioning.** `AdminCategoriesFacade.reorder(id, targetOrder)` took the
|
||||
dropped-on row's numeric `order` and wrote that exact value onto the
|
||||
dragged category - leaving two siblings tied on the same `order` instead of
|
||||
actually reordering. `AdminCategoriesLocalGateway.loadCategories()` sorts
|
||||
by `left.order - right.order` using `Array.prototype.sort` (stable), so
|
||||
ties break by original array position, not by drop intent - some drags
|
||||
silently no-op. Worse, every seeded category starts at `order: 0`
|
||||
(`AdminCategoriesLocalGateway.toAdminCategory`), so on fresh data *every*
|
||||
drag was a no-op: dropping item C onto item A sent `{ id: 'c', order: 0 }`,
|
||||
which was already A's (and C's) value. Fixed by changing the drag payload
|
||||
to carry the target's `id` (not its ambiguous/duplicable `order` value);
|
||||
`reorder(id, targetId)` now computes the full same-parent sibling sequence
|
||||
with the dragged item spliced into the target's position and persists
|
||||
sequential `0..n-1` order values for every sibling whose order actually
|
||||
changed. Cross-parent drops (`dragged.parentId !== target.parentId`) are a
|
||||
no-op, matching the tree UI's existing scope (no reparent-via-drag support
|
||||
before or after this fix). Updated end to end:
|
||||
`AdminCategoriesListComponent`'s `reorder` output now emits
|
||||
`{ id, targetId }` instead of `{ id, targetOrder }`; the list page binding
|
||||
follows.
|
||||
- Verified live: seeded 3 siblings with distinct orders (0/1/2), dragged
|
||||
the last onto the first. Before the fix, the gateway only received
|
||||
`{ id: 'c', order: 0 }` (tying `a` and `c`). After the fix, the gateway
|
||||
receives the correct 3-way reshuffle: `c:0, a:1, b:2`.
|
||||
|
||||
A third bug, found in the same pass, was fixed in a follow-up commit:
|
||||
`admin-product-form.component` and `admin-category-form.component` both
|
||||
hardcoded their translation-tab locales to `['en', 'ru', 'hy']` instead of
|
||||
reading the tenant's actual configured `supportedLocales` (which live on
|
||||
`ProjectEditorFacade.bootstrap()`, the same source
|
||||
`static-pages-editor.component.ts` already reads correctly) - neither
|
||||
`AdminProductsFacade` nor `AdminCategoriesFacade` depended on project-editor
|
||||
state at all before this. Fixed by giving both facades a `supportedLocales`
|
||||
computed (`bootstrap()?.localization.supportedLocales ?? ['en']`) and an
|
||||
`ensureLocalesLoaded()` that calls `ProjectEditorFacade.loadBootstrap()` if
|
||||
it hasn't loaded yet (same lazy-load pattern
|
||||
`AdminDashboardFacade.ensureLoaded()` already uses for the same dependency);
|
||||
both editor pages call it in their constructor and pass
|
||||
`[locales]="facade.supportedLocales()"` down to the form components, which
|
||||
now iterate a `locales: string[]` `@Input()` instead of the literal array.
|
||||
Verified live: both `facade.supportedLocales()` and the form's bound
|
||||
`locales` changed from the hardcoded `['en','ru','hy']` to the real tenant
|
||||
order `['ru','en','hy']` (default locale first), confirmed by the rendered
|
||||
tab order in both editors.
|
||||
|
||||
The already-documented, deliberately-scoped-down items from earlier sprints
|
||||
(related-products picker limited to the current page, not a full catalog
|
||||
search; the ~178 untranslated `adminXxx.*` i18n keys) were re-confirmed
|
||||
during this pass and are unchanged - see their existing sections above and
|
||||
`docs/KNOWN-ISSUES.md`.
|
||||
|
||||
## Known gaps / backend needs
|
||||
|
||||
- **Dashboard metrics endpoint.** Categories/Products counts are computed
|
||||
client-side from `BackofficeDataService` (itself mock/API-switchable via
|
||||
`BACKOFFICE_DATA_PROVIDER`). A dedicated `/builder/dashboard/summary`-style
|
||||
endpoint would let `AdminDashboardMetricsGateway` return richer data
|
||||
(real-time counts, trend deltas) without touching the facade or cards.
|
||||
- **Orders/Revenue have no backend at all** (see above) - needs an order
|
||||
domain and revenue aggregation before these cards can show real data.
|
||||
- **Recent Activity is local-only**, scoped to the browser/tenant via
|
||||
localStorage (`adminDashboard.activityHistory.v1`), same limitation as the
|
||||
existing draft-save local storage. It will not show another editor's
|
||||
activity until a real audit-log endpoint exists.
|
||||
- **Admin authorization is still not enforced server-side** (see
|
||||
`Project-Editor.md` - "Admin Authentication" section); this sprint does not
|
||||
change that. Nothing new here beyond routing/dashboard.
|
||||
@@ -1,274 +0,0 @@
|
||||
> **ARCHIVED 2026-07-26.** Superseded by [`docs/BACKEND_INTEGRATION.md`](../BACKEND_INTEGRATION.md), the single canonical backend integration document. Kept for history only — do not implement against this file.
|
||||
|
||||
# Admin Authentication — Ed25519 Foundation
|
||||
|
||||
Status: **FRONTEND PREPARED, NOT LIVE.** Everything in this document describes
|
||||
code that exists in `src/app/core/auth/` today, wired to endpoints that do
|
||||
not exist on the backend yet. No route currently requires this flow — the
|
||||
live admin gate remains the Telegram-QR-based `AdminAuthService` /
|
||||
`adminAuthGuard` (`src/app/core/admin-auth/`, documented in
|
||||
`docs/BACKEND_API.md` §2.4–2.5). This module is the
|
||||
integration target once the backend ships the endpoints below.
|
||||
|
||||
Do not point any live route's `canActivate` at `ed25519AuthGuard` until the
|
||||
backend endpoints in §API Contracts exist and have been verified — doing so
|
||||
before then would lock every admin out.
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
`docs/BACKEND_API.md` §2.5 documents the current system's
|
||||
biggest security gap: admin and customer login hit the *same* Telegram
|
||||
session endpoint, so the backend has no way to distinguish an admin login
|
||||
attempt from a customer one at the moment of login — authorization is
|
||||
effectively unenforced. Ed25519 challenge/response auth closes this by
|
||||
requiring proof of possession of a specific, pre-registered private key
|
||||
before a session is ever issued, instead of "any Telegram account that
|
||||
happened to scan the right QR code."
|
||||
|
||||
## 2. Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as Admin (browser)
|
||||
participant FE as Frontend (AuthService)
|
||||
participant BE as Backend
|
||||
|
||||
Admin->>FE: Click "Sign in"
|
||||
FE->>BE: GET /api/admin/auth/challenge
|
||||
BE-->>FE: { nonce, issuedAt, expiresAt }
|
||||
FE->>FE: Ed25519KeypairService.sign(nonce)<br/>(WebCrypto, non-extractable private key)
|
||||
FE->>BE: POST /api/admin/auth/verify<br/>{ publicKey, signature, nonce }
|
||||
alt signature valid & publicKey is a provisioned admin key
|
||||
BE-->>FE: 200 { token, refreshToken }
|
||||
FE->>FE: SessionService.activate(tokens)<br/>decode JWT claims, schedule refresh
|
||||
FE-->>Admin: Redirect to /backoffice
|
||||
else invalid signature / unknown key / expired nonce
|
||||
BE-->>FE: 401/403
|
||||
FE-->>Admin: Redirect to /admin-login/error/invalid-signature
|
||||
end
|
||||
```
|
||||
|
||||
### Refresh sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend (SessionService)
|
||||
participant IC as authInterceptor
|
||||
participant BE as Backend
|
||||
|
||||
Note over FE: Timer fires ~60s before JWT exp
|
||||
FE->>BE: POST /api/admin/auth/refresh { refreshToken }
|
||||
alt refresh token still valid
|
||||
BE-->>FE: 200 { token, refreshToken }
|
||||
FE->>FE: activate(tokens) - reschedules next refresh
|
||||
else refresh token expired/revoked
|
||||
BE-->>FE: 401
|
||||
FE->>FE: SessionService.markExpired()
|
||||
FE-->>FE: Route to /admin-login/error/session-expired
|
||||
end
|
||||
|
||||
Note over IC: Reactive path - any 401 on an admin request
|
||||
IC->>BE: Admin API request (expired token)
|
||||
BE-->>IC: 401
|
||||
IC->>BE: POST /api/admin/auth/refresh (single retry)
|
||||
alt refresh succeeds
|
||||
BE-->>IC: 200 tokens
|
||||
IC->>BE: Retry original request with new token
|
||||
else refresh fails
|
||||
IC-->>FE: Propagate error, route to session-expired
|
||||
end
|
||||
```
|
||||
|
||||
## 3. Ed25519 flow, step by step
|
||||
|
||||
1. **Key generation (once per device):** `Ed25519KeypairService.getOrCreateKeyPair()`
|
||||
generates a non-extractable Ed25519 keypair via `crypto.subtle.generateKey`
|
||||
and persists the `CryptoKey` handles in IndexedDB (`admin-auth-ed25519` DB).
|
||||
The private key is never exported, serialized, or transmitted — by
|
||||
construction, not by convention.
|
||||
2. **Registering the public key with the backend is out of scope for this
|
||||
frontend.** An Owner/Administrator must associate a new device's
|
||||
`publicKeyBase64` with an admin account through some out-of-band
|
||||
mechanism (e.g. a backend admin tool, a one-time enrollment link) before
|
||||
that device can complete step 4. This document does not prescribe that
|
||||
mechanism — it is a backend/ops concern.
|
||||
3. **Challenge:** `GET /api/admin/auth/challenge` returns a fresh `nonce` the
|
||||
client must sign before `expiresAt`.
|
||||
4. **Sign:** the raw `nonce` string is signed with the device's private key
|
||||
(`Ed25519KeypairService.sign`), producing a base64 signature.
|
||||
5. **Verify:** `POST /api/admin/auth/verify` sends `{ publicKey, signature,
|
||||
nonce }`. The backend re-derives the signed message from the nonce it
|
||||
issued, verifies the signature against its own record of that
|
||||
`publicKey → admin account` mapping, and only then issues tokens.
|
||||
6. **Session:** the returned `{ token, refreshToken }` pair is stored
|
||||
(`SessionService`, `localStorage: ed25519AdminToken` /
|
||||
`ed25519AdminRefreshToken`) and the JWT is decoded client-side for
|
||||
`role`/`exp` — decoding only, never signature verification (the frontend
|
||||
has no trusted key to check it against).
|
||||
|
||||
## 4. API contracts
|
||||
|
||||
All under `{environment.authApiUrl}/api/admin/auth` (see
|
||||
`src/environments/environment.ts`). None of these exist on the backend
|
||||
today — this is the contract the frontend was built against, not a
|
||||
confirmed backend spec.
|
||||
|
||||
| Method | Path | Request body | Response | Notes |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/challenge` | — | `200 AuthChallenge` | `{ nonce, issuedAt, expiresAt }`, all ISO 8601 except `nonce` |
|
||||
| POST | `/verify` | `VerifySignatureRequest` | `200 AuthTokenPair` \| `401` \| `403` | `{ publicKey, signature, nonce }` → `{ token, refreshToken }` |
|
||||
| POST | `/refresh` | `RefreshTokenRequest` | `200 AuthTokenPair` \| `401` | `{ refreshToken }` → new pair (rotation expected — old refresh token should be invalidated server-side) |
|
||||
| POST | `/logout` | `{ refreshToken }` | `204` | Should revoke the refresh token server-side; frontend clears local state regardless of response |
|
||||
|
||||
Types: `src/app/core/auth/models/auth-api.model.ts`.
|
||||
|
||||
## 5. JWT claims
|
||||
|
||||
```ts
|
||||
interface JwtClaims {
|
||||
sub: string; // admin account id
|
||||
role: AdminRole; // 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'
|
||||
iat: number; // seconds since epoch
|
||||
exp: number; // seconds since epoch
|
||||
publicKey: string; // the Ed25519 public key this token was issued for
|
||||
}
|
||||
```
|
||||
|
||||
The frontend decodes these (`JwtService.decode`) for UX only — role-based UI
|
||||
gating, expiry countdowns, refresh scheduling. **Every admin API request
|
||||
must be independently authorized server-side**; a decoded-but-unverified
|
||||
claim is not proof of anything to the backend.
|
||||
|
||||
## 6. Permission model
|
||||
|
||||
Five roles, coarse-grained permission keys (`src/app/core/auth/models/permission.model.ts`):
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| Owner | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` |
|
||||
| Administrator | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` |
|
||||
| Editor | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` |
|
||||
| Support | `backoffice.read` |
|
||||
| ReadOnly | `backoffice.read`, `builder.read` |
|
||||
|
||||
This is deliberately coarse and mirrors the existing bootstrap-level
|
||||
`PermissionsConfig` shape (`src/app/shared/models/config/permissions.model.ts`).
|
||||
Finer-grained, per-domain permissions (e.g. "can edit prices but not delete
|
||||
products") stay server-side until a real permission model exists there —
|
||||
see `docs/BACKEND_API.md` §"Admin-role-required". Use
|
||||
`PermissionService.has(permission)` / `permissionGuard(permission)` to gate
|
||||
UI and routes; never treat a passing client-side check as authorization by
|
||||
itself.
|
||||
|
||||
## 7. Error screens
|
||||
|
||||
Single component (`AuthErrorPageComponent`, `/admin-login/error/:code`)
|
||||
renders all five, keyed by route param:
|
||||
|
||||
| Code | Trigger | User action offered |
|
||||
|---|---|---|
|
||||
| `session-expired` | Refresh token rejected/expired | Sign in again |
|
||||
| `invalid-signature` | `verify` returns 401/403 during login | Try again |
|
||||
| `unauthorized` | Route guard sees no active session | Sign in |
|
||||
| `forbidden` | `permissionGuard` denies (authenticated but insufficient role) | Back to dashboard |
|
||||
| `backend-unavailable` | Network error / 5xx / status 0 | Retry |
|
||||
|
||||
`authErrorCodeFromStatus` (`models/auth-error.model.ts`) maps HTTP status →
|
||||
code: `401→unauthorized`, `403→forbidden`, `0→backend-unavailable`,
|
||||
`5xx→backend-unavailable`, else `unauthorized`. `AuthService.login()`
|
||||
additionally maps any failure during the challenge/sign/verify sequence to
|
||||
`invalid-signature` when it isn't a clearer HTTP-status-derived code.
|
||||
|
||||
## 8. Refresh lifecycle
|
||||
|
||||
- On `SessionService.activate(tokens)`, a timer is scheduled for
|
||||
`max(exp - now - 60s, 5s)` — refresh fires ~60 seconds before expiry so a
|
||||
concurrent request never races an expiring token.
|
||||
- **Proactive path:** the timer fires `AuthService.refresh()` directly.
|
||||
- **Reactive path:** `authInterceptor` catches a 401 on any admin-gated
|
||||
request, attempts one `refresh()`, retries the original request once on
|
||||
success, and routes to `session-expired` on failure. It does not retry
|
||||
more than once — a second 401 after a successful-looking refresh means
|
||||
something is wrong server-side, not a transient race.
|
||||
- `SessionService.restore()` runs on app bootstrap (call
|
||||
`AuthFacade.restoreSession()` from an app initializer once this flow goes
|
||||
live) — reads persisted tokens, decodes claims, and either resumes with a
|
||||
scheduled refresh or marks `expired` without any network call, so a stale
|
||||
session is caught before it reaches any component.
|
||||
|
||||
## 9. Module map
|
||||
|
||||
```
|
||||
src/app/core/auth/
|
||||
├── auth.routes.ts # /admin-login, /admin-login/error/:code
|
||||
├── models/
|
||||
│ ├── auth-api.model.ts # AuthChallenge, VerifySignatureRequest, AuthTokenPair, JwtClaims
|
||||
│ ├── auth-error.model.ts # AuthErrorCode, authErrorCodeFromStatus()
|
||||
│ └── permission.model.ts # AdminRole, Permission, ROLE_PERMISSIONS
|
||||
├── services/
|
||||
│ ├── ed25519-keypair.service.ts # WebCrypto keygen/sign, IndexedDB persistence
|
||||
│ ├── auth-api.service.ts # HttpClient calls to the 4 endpoints in §4
|
||||
│ ├── jwt.service.ts # decode-only JWT parsing
|
||||
│ ├── session.service.ts # token/claims state, persistence, refresh scheduling
|
||||
│ ├── permission.service.ts # role -> permission set
|
||||
│ ├── auth.service.ts # orchestrates challenge -> sign -> verify -> refresh -> logout
|
||||
│ └── auth-facade.service.ts # public surface for components
|
||||
├── interceptors/
|
||||
│ └── auth.interceptor.ts # Authorization: Bearer + 401 refresh-and-retry
|
||||
├── guards/
|
||||
│ ├── ed25519-auth.guard.ts # requires SessionService.isAuthenticated()
|
||||
│ └── permission.guard.ts # permissionGuard(permission) factory
|
||||
└── pages/
|
||||
├── admin-login-page.component.* # sign-in UI
|
||||
└── auth-error-page.component.* # parameterized error screen (§7)
|
||||
```
|
||||
|
||||
`AuthFacade` is the only thing components/pages should depend on;
|
||||
`AuthService`/`SessionService`/`PermissionService` are internal
|
||||
collaborators reachable through it.
|
||||
|
||||
## 10. Cutover plan (when the backend ships)
|
||||
|
||||
1. Verify the four endpoints in §4 against a real backend, including error
|
||||
shapes.
|
||||
2. Register `authInterceptor` in `app.config.ts`'s `withInterceptors([...])`
|
||||
list (currently not registered).
|
||||
3. Decide the relationship to the existing Telegram flow: replace
|
||||
`adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let
|
||||
role/tenant config pick — this is a product decision, not made here.
|
||||
4. Wire `AuthFacade.restoreSession()` into an `APP_INITIALIZER` (or root
|
||||
component `ngOnInit`) so a page refresh restores state before any guard
|
||||
runs.
|
||||
5. Only after 1–4: point `/backoffice` and `/edit`'s `canActivate` at
|
||||
`ed25519AuthGuard` (and `permissionGuard(...)` where a route needs a
|
||||
specific role).
|
||||
|
||||
## 11. Security considerations
|
||||
|
||||
- **Private key never leaves the device.** Generated non-extractable via
|
||||
WebCrypto; `Ed25519KeypairService` has no export path. Losing the device
|
||||
means losing the key — key rotation/recovery (revoking a lost device's
|
||||
public key, provisioning a new one) is a backend/ops process, not
|
||||
implemented here.
|
||||
- **The frontend is not the authorization boundary.** Every admin
|
||||
request must be independently checked server-side against the caller's
|
||||
actual role, exactly as `docs/BACKEND_API.md` §2.5
|
||||
already states for the Telegram flow. A decoded JWT claim or a passing
|
||||
`PermissionService.has()` check is UX, not proof.
|
||||
- **Refresh tokens should rotate.** Every `POST /refresh` response is
|
||||
expected to include a *new* refresh token; the backend should invalidate
|
||||
the one just used. The frontend always stores whatever pair it receives
|
||||
and never reuses an old refresh token after a successful rotation.
|
||||
- **CSRF/replay:** the nonce from `/challenge` must be single-use and
|
||||
time-boxed server-side (`expiresAt`) — the frontend enforces nothing here
|
||||
beyond passing the nonce back unmodified; replay protection is the
|
||||
backend's responsibility.
|
||||
- **No fallback to unsigned auth.** There is no code path in this module
|
||||
that issues a session without a valid signature. If the backend is
|
||||
unreachable, the user sees `backend-unavailable`, never a degraded or
|
||||
bypassed login.
|
||||
- **Dev bypass exclusion:** unlike `AdminAuthService.devBypassLogin()` in
|
||||
the Telegram flow, this module intentionally has no dev bypass — an
|
||||
Ed25519 keypair is cheap to generate locally, so local testing should
|
||||
point at a real (even if mocked-in-dev) `/challenge`/`/verify` pair
|
||||
rather than fabricating a session.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,92 +0,0 @@
|
||||
> **ARCHIVED 2026-07-26.** Superseded by [`docs/BACKEND_INTEGRATION.md`](../BACKEND_INTEGRATION.md), the single canonical backend integration document. Kept for history only — do not implement against this file.
|
||||
|
||||
# Remaining backend work (everything except auth/session)
|
||||
|
||||
Companion to the `API-CONTRACT.md` backend delivered separately (covers `GET /bootstrap`
|
||||
transport + `/users/sessions/*` — done, see prior conversation). This file lists what's
|
||||
still outstanding. Full request/response shapes, TypeScript
|
||||
interfaces, and validation rules for every item below already exist in
|
||||
[`docs/BACKEND_API.md`](BACKEND_API.md) — this is a prioritized
|
||||
punch list with links into that spec, not a duplicate of it. **Do not re-document
|
||||
endpoint shapes here** — edit the master spec if a shape needs to change.
|
||||
|
||||
Status legend (same as master spec): **PLANNED** = shape fully specified client-side,
|
||||
served by a mock gateway today, nothing built server-side yet. **FUTURE** = reserved
|
||||
contract only, no urgency. Bootstrap's *content* (branding/theme/nav values, not the
|
||||
`GET /bootstrap` transport itself) is also still outstanding — see P0 below.
|
||||
|
||||
---
|
||||
|
||||
## Status legend for this list
|
||||
|
||||
**DONE** = wired end-to-end on the frontend (real HTTP gateway or real call site, no mock
|
||||
left in the path). **PLANNED** = shape fully specified client-side, still served by a mock
|
||||
gateway, nothing wired yet. Everything below that isn't marked DONE is still open.
|
||||
|
||||
## P0 — blocks going live at all
|
||||
|
||||
| # | Item | Status | Spec section |
|
||||
|---|---|---|---|
|
||||
| 1 | `bootstrap.json` real content (branding, theme, navigation, seo) — currently default stubs per backend's own note in API-CONTRACT.md | open | [§4](BACKEND_API.md#4-bootstrap) |
|
||||
| 2 | Builder — bootstrap draft/publish/validate (`GET/PUT /builder/bootstrap/draft`, `POST /builder/bootstrap/publish`, `POST /builder/bootstrap/validate`) — this is how the Marketplace Builder actually saves anything | open | [§6.7](BACKEND_API.md#67-builder--bootstrap-draftpublishvalidate-planned-highest-priority) |
|
||||
| 3 | Backoffice — Products CRUD + variants | open | [§6.10](BACKEND_API.md#610-backoffice--products-planned), DTOs [§7.2](BACKEND_API.md#72-products--srcappfeaturesadminproductsmodelsadmin-productmodelts) |
|
||||
| 4 | Backoffice — Categories CRUD (tree) | **DONE** — `admin-categories-api.gateway.ts` + `admin-categories-gateway.token.ts` wired, swaps on `RuntimeProviderStrategyService` | [§6.9](BACKEND_API.md#69-backoffice--categories-planned), DTOs [§7.1](BACKEND_API.md#71-categories--srcappfeaturesadmincategoriesmodelsadmin-categorymodelts) |
|
||||
| 5 | Media upload/delete/replace pipeline | open | [§6.18](BACKEND_API.md#618-media-planned--adr-0002), [§10](BACKEND_API.md#10-media) |
|
||||
|
||||
## P1 — needed for real order/commerce flow
|
||||
|
||||
| # | Item | Status | Spec section |
|
||||
|---|---|---|---|
|
||||
| 6 | Backoffice — Orders CRUD + status transitions | open | [§6.11](BACKEND_API.md#611-backoffice--orders-planned), state machine [§8.1](BACKEND_API.md#81-orders--adminorderstatus) |
|
||||
| 7 | Backoffice — Transactions (list/detail, tied to orders) | open | [§6.12](BACKEND_API.md#612-backoffice--transactions-planned) |
|
||||
| 8 | Order creation — checkout calls `POST /orders` on payment success | **DONE** — `ApiService.createOrder()` + `CartComponent.recordOrder()`, fire-and-forget alongside `clearCart()`, doesn't touch the frozen payment call chain | [§16.9](BACKEND_API.md#169-order-creation-future--no-order-creation-endpoint-exists-anywhere-yet) |
|
||||
| 9 | Backoffice — Users/roles/invitations | open | [§6.13](BACKEND_API.md#613-backoffice--users-roles-invitations-planned) |
|
||||
| 10 | Backoffice — Moderation (review + report status transitions) | open | [§6.14](BACKEND_API.md#614-backoffice--moderation-reviews--reports-planned), state machines [§8.4](BACKEND_API.md#84-reviews--adminreviewstatus)/[§8.5](BACKEND_API.md#85-reports--adminreportstatus) |
|
||||
|
||||
## P2 — dashboards / operational visibility
|
||||
|
||||
| # | Item | Status | Spec section |
|
||||
|---|---|---|---|
|
||||
| 11 | Backoffice — Dashboard metrics & recent activity | open | [§6.15](BACKEND_API.md#615-backoffice--dashboard-metrics--recent-activity-planned) |
|
||||
| 12 | Backoffice — Monitoring (all but Health) | open | [§6.16](BACKEND_API.md#616-backoffice--monitoring-planned-except-health) |
|
||||
| 13 | Backoffice — Analytics summary (real once orders are real) | open | [§6.17](BACKEND_API.md#617-backoffice--analytics-mostly-future--no-data-source) |
|
||||
| 14 | Builder — Content pages / CMS | open | [§6.8](BACKEND_API.md#68-builder--content-pages--cms-planned) |
|
||||
|
||||
## P3 — nice-to-have, no urgency
|
||||
|
||||
| # | Item | Status | Spec section |
|
||||
|---|---|---|---|
|
||||
| 15 | Search suggestions / catalog filters | open | [§6.6](BACKEND_API.md#66-search--autocomplete--trending-planned) |
|
||||
| 16 | Cross-device wishlist/compare/saved-searches sync — backend confirmed id-only stays, added `GET /items/batch?ids=` for hydration. Frontend needs `UserExperienceRepository` redesign: id-array + local product cache hydrated via the batch endpoint, replacing today's fully-synchronous denormalized-object storage | open (unblocked, not started) | [§6.6](BACKEND_API.md#66-search--autocomplete--trending-planned) |
|
||||
| 17 | Analytics traffic/funnels/heatmaps — needs a tracking pipeline that doesn't exist yet, not just an endpoint | open | [§6.17](BACKEND_API.md#617-backoffice--analytics-mostly-future--no-data-source) |
|
||||
| 18 | Sitemap — dynamic generation (static baseline today) | open (server-side, no frontend action) | [§6.19](BACKEND_API.md#619-sitemap-future--static-baseline-only-today) |
|
||||
|
||||
---
|
||||
|
||||
## Explicitly not in this list
|
||||
|
||||
- Auth / Telegram session (`GET /bootstrap` transport, `/users/sessions/*`) — covered by
|
||||
backend's `API-CONTRACT.md`, frontend wiring matches it exactly.
|
||||
- `authApiUrl` env value — **fixed**, now points at the same host as `apiUrl`
|
||||
(`https://api.dexarmarket.ru:445`) in both `environment.ts` and `environment.production.ts`.
|
||||
- `AdminWebSessionID` header — **fixed a real bug**: the interceptor only attached it to
|
||||
URLs containing `/admin/`, but every real backend path is `/backoffice/*`, `/builder/*`,
|
||||
`/media/*` — none of those matched, so every new admin call would have silently gone out
|
||||
with no admin auth header at all. Broadened the guard in `admin-auth-headers.interceptor.ts`.
|
||||
- `telegramBot` username — still unverified against `bot.go`'s `startbot()`.
|
||||
- Frontend deploy domain vs. CORS allow-list — **decided**: frontend and API stay on the
|
||||
same domain, so this is a non-issue by design rather than something to reconcile against
|
||||
an allow-list.
|
||||
- Payments — frozen, unchanged, out of scope per [§2.8](BACKEND_API.md#28-payments-frozen-documented-for-completeness).
|
||||
- Storefront reads/writes (categories, items, search, cart, reviews) — already real HTTP,
|
||||
already working, no backend work needed. See [§6.1](BACKEND_API.md#61-storefront-reads-current--frozen-shapes-srcappservicesapiservicets)–[§6.2](BACKEND_API.md#62-storefront-writes-current--frozen-shapes).
|
||||
|
||||
## For every open item above, when implementing
|
||||
|
||||
Read the interface + model file cited in the linked spec section before writing the
|
||||
endpoint — the shape is already fixed by the frontend gateway interface, not up for
|
||||
renegotiation without a frontend change. Follow the pattern now established for Categories
|
||||
(`admin-categories-api.gateway.ts` + `admin-categories-gateway.token.ts`): one `*ApiGateway`
|
||||
class implementing the existing `*Gateway` interface, plus one `InjectionToken` factory that
|
||||
picks mock vs. real off `RuntimeProviderStrategyService`, then switch the facade(s) to inject
|
||||
the token instead of the concrete mock class. See [§14](BACKEND_API.md#14-backend-replacement-pattern) for the general pattern.
|
||||
@@ -1,44 +0,0 @@
|
||||
> **ARCHIVED 2026-07-26.** Despite its filename this was a shipped-history changelog, not a forward roadmap — superseded by [`../NEXT_PHASE.md`](../NEXT_PHASE.md) (the one roadmap), [`../CHANGELOG.md`](../../CHANGELOG.md) (shipped history), and [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md)/[`../KNOWN-ISSUES.md`](../KNOWN-ISSUES.md)/[`../PRODUCT_BACKLOG.md`](../PRODUCT_BACKLOG.md)/[`../FUTURE_FEATURES.md`](../FUTURE_FEATURES.md) (open items, now category-split). Kept for history only.
|
||||
|
||||
# Frontend Roadmap
|
||||
|
||||
Status snapshot, refreshed from recent commits only. Full sprint history: `SPRINT-PLAN.md` (removed, see git history). Open bugs: `docs/KNOWN-ISSUES.md`.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
**RC-Visual-02 — Composition audit** (storefront, builder, backoffice)
|
||||
Fixed undefined CSS theme vars, hand-rolled skeletons/empty-states migrated to shared `app-skeleton`/`app-empty-state`/`app-button`, missing `<th scope="col">`, dead CSS (~530-line unused cart `.alt` theme). Detail: `UI-COMPOSITION-REVIEW.md` (removed, see git history).
|
||||
|
||||
**RC-Premium-01 — Storefront premium UX polish**
|
||||
Visual/interaction polish on top of the RC-Visual-02 baseline — no redesign, no logic/route changes. Fixed color-only state signaling app-wide (added `aria-pressed`/`aria-current`/`aria-live` + icon/checkmark pairing to selected swatches, active filters/tabs/sort, toggle buttons), normalized remaining hardcoded hex to design tokens, added hover/focus-visible/active/disabled states across interactive controls, capped legal/CMS prose at 70ch, converted FAQ to native `<details>` disclosures. Four commits (Home/Catalog/Search, Product/Compare/Wishlist, Cart/Checkout, Static Pages). Detail: `STORE_FRONT_UX_REVIEW.md` (removed, see git history).
|
||||
|
||||
**RC STORE-01 — Storefront cleanup**
|
||||
Closed 2 of the 5 gaps RC-Premium-01 deliberately deferred: category/search skeleton markup migrated to shared `app-skeleton`, dead cart `.email-form` markup/CSS removed. The other 3 (payment modal, cart confirm() dialog, untokenized colors) need an architecture/design-system decision, correctly left alone again. Detail: `STORE_REVIEW.md` (removed, see git history).
|
||||
|
||||
**RC PERF-01 — Performance audit** (production-readiness, app-wide)
|
||||
Initial bundle **1.47 MB → 1.12 MB raw (−24%)**: biggest win was lazy-loading en/hy i18n packs (346 KB were eagerly loaded regardless of visitor language), plus a dead `items-carousel`/primeng-only component deleted, dead global CSS removed. RxJS/change-detection audit found the codebase already clean (0 leaks, 190/191 components already OnPush). Detail: `PERFORMANCE_REPORT.md` (removed, see git history).
|
||||
|
||||
**RC A11Y-01 — WCAG 2.1 AA audit** (storefront, builder, backoffice)
|
||||
Added the app's first skip link (didn't exist anywhere before), fixed cart's custom payment modals having zero focus-trap, fixed `app-icon`'s "decorative by default" claim never actually being implemented, fixed 2 keyboard-inaccessible drag-and-drop reorder UIs (Builder homepage/footer, Backoffice categories), fixed an undefined `--color-primary` token in Builder, fixed admin sidebar nav announcing itself as "Dashboard" everywhere. Contrast fixes applied where safe; genuine brand-color contrast failures flagged for theme-owner sign-off, not changed unilaterally. Detail: `ACCESSIBILITY_REPORT.md` (removed, see git history).
|
||||
|
||||
**Release Candidate — live browser walkthrough** (storefront, builder, backoffice)
|
||||
Found and fixed **2 P0s**: (1) `language.guard.ts`'s legacy-URL redirect broke query params on every route app-wide (silently dead-ended any bookmarked/shared deep link with query params); (2) Backoffice Categories CRUD was completely broken end-to-end — wrong gateway-resolution fallback always picked the real HTTP gateway instead of the local mock in this environment, so every create/publish silently failed with zero user feedback. Plus 6 P1s (cart description, compare table raw enum values, search empty-state messaging, footer link 404, missing placeholder image, builder save-bar reset-state bug, backoffice mislabeled button). Detail: `RELEASE_REPORT.md` (removed, see git history).
|
||||
|
||||
## Sprint status
|
||||
|
||||
**Sprint 30 — Final Release**: verify pass re-run 2026-07-23 (tsc --noEmit, `npm run build`, `arch:check:boundaries`, `arch:check:cycles`) — all green, only pre-existing bundle-budget warning. Working tree otherwise clean. Only remaining item: `git push` of 10 local `B2B` commits to `origin/B2B` — awaiting explicit user go-ahead (declined once already this sprint, per safety rules re-ask each time). Full checklist: `SPRINT-PLAN.md` (removed, see git history).
|
||||
|
||||
## Known open items (not yet scheduled)
|
||||
|
||||
As of the 2026-07-26 Final Project Closeout, open items are split by category instead of one mixed list:
|
||||
- Real, reproducible frontend bugs: `docs/KNOWN-ISSUES.md` (one open item).
|
||||
- Items needing a client/business decision (dark mode, brand-color contrast, Contacts page content, advanced analytics, payment providers): `docs/PRODUCT_BACKLOG.md`.
|
||||
- Nice-to-have, non-blocking future work (Angular 22, bundle splitting, cart-modal composition cleanup, hero-spacing investigation): `docs/FUTURE_FEATURES.md`.
|
||||
- Backend integration: fully specified, not yet implemented — the single canonical spec is `docs/BACKEND.md`.
|
||||
- Release blockers: `docs/TODO.md` — currently none.
|
||||
|
||||
Overall status: `docs/PROJECT_STATUS.md`.
|
||||
|
||||
## Not audited / out of scope
|
||||
|
||||
Settings (no route exists), Diagnostics (dev-only, excluded from production).
|
||||
499
docs/backend/BACKEND-INTEGRATION.md
Normal file
499
docs/backend/BACKEND-INTEGRATION.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# Backend — the whole thing, one file
|
||||
|
||||
**Date:** 2026-08-22 · **Branch of record:** `improvements/fork-harvest`
|
||||
|
||||
This is the single source of truth for the marketplaces backend. It replaces the former `docs/backend/` set (Phase 1–10, Track A/S, the handoffs, the partner and harvest docs) — all of it is folded in here. The frontend is Angular 22, built and waiting; **there is no backend yet.** Everything below is the wire contract and the invariants the frontend needs, never DB schema or service boundaries, which stay the backend's own call.
|
||||
|
||||
> **The rule (keep this file alive).** When a backend need is added, a contract changes, or something ships, update THIS file in the same change — the relevant section and the change log at the bottom (§14). One file, always current. Do not create a new backend `.md`; add a section here.
|
||||
|
||||
---
|
||||
|
||||
## 0. Map
|
||||
|
||||
| § | Area | Was |
|
||||
|---|---|---|
|
||||
| 1 | System shape — multi-tenancy, auth, infra state | Handoff §1–4 |
|
||||
| 2 | Release invariants (the gate) | Handoff §0 |
|
||||
| 3 | How work lands — PR & release discipline | Handoff §0a |
|
||||
| 4 | Cross-cutting mechanisms | Phase 1/Track S/Partner |
|
||||
| 5 | Money, FX, payment state machine | Phase 1 |
|
||||
| 6 | Cart & checkout | Phase 6 |
|
||||
| 7 | Payments, reconciliation, refunds, settlements | Phase 7 |
|
||||
| 8 | Catalog, offers, inventory, fulfillment | Phase 3 |
|
||||
| 9 | Orders, events, notifications | Phase 2 |
|
||||
| 10 | Identity & messaging (VK/Yandex/Telegram/MAX) | Phase 8 |
|
||||
| 11 | Tenant registry, domains, publish | Phase 9 |
|
||||
| 12 | Sellers · connectors · content · analytics · partner API | Phase 5/4/10, Track A, Partner |
|
||||
| 13 | Infra, tenant routing, deploy | Tenant-API handoff + hardening |
|
||||
| — | Acceptance tests · build order · dev setup · open decisions · change log | §2 end, §15–18, §14 |
|
||||
|
||||
New endpoints use `/api/v2/...`; legacy endpoints (documented in `../../BACKEND-API-REFERENCE.md`) are not being migrated.
|
||||
|
||||
---
|
||||
|
||||
## 1. System shape
|
||||
|
||||
### 1.1 Multi-tenancy — shapes every endpoint
|
||||
|
||||
One deployed bundle serves **every** customer domain; there is no per-tenant build. The chain: `TenantResolverService` reads the browser hostname → `ApiConfigService` uses one API host per base domain (`example.com` and `store1.example.com` both use `api.example.com`) → nginx validates the browser origin and forwards the full storefront hostname as `X-Storefront-Host` → the backend resolves the tenant from that trusted header, **never** from the shared API `Host`, and treats the frontend-supplied hostname as an untrusted hint, deriving real scope from the authenticated session. A tenant must never read another tenant's data — return `403`, not an empty result. Bootstrap carries only what's needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages); never products, orders, cart, or users.
|
||||
|
||||
**`published: boolean`** — required top-level field on the bootstrap response (2026-08-22). `true` once the marketplace has a `publishedRevision` (§11); `false` while it has none — every other field may then be anything, including a partial/placeholder row, since the frontend ignores the rest of the body and renders its own built-in all-features-on placeholder instead (`ConfigService`, see [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md)). Field absent (old backend) is read as `true` for backward compatibility — do not omit it once built.
|
||||
|
||||
### 1.2 Auth — read before writing any endpoint
|
||||
|
||||
Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PACKAGES-USAGE.md`). Two mechanisms exist client-side:
|
||||
|
||||
- **Telegram QR/session (live).** `{authApiUrl}/users/sessions` — `POST` create, `GET /{id}` poll, `DELETE /{id}` logout. A clean implementation returns `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }`.
|
||||
- **Ed25519 challenge/response (not built).** `GET /api/admin/auth/challenge`, `POST /api/admin/auth/verify|refresh|logout`.
|
||||
|
||||
**The critical gap:** the session API has no concept of "admin." The frontend only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side** — client-side guards are UI convenience, never security. Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
||||
|
||||
**Admin credential (login/password) auth — required, not yet built.** `admin.gorbushka.market` can authenticate via Telegram today; login/password is not implemented, so the frontend must not validate or embed admin credentials, and the Ed25519 `/admin-login` page is not production-ready (its challenge/verify endpoints don't exist). Tenant identity comes only from nginx's trusted `X-Storefront-Host` — never from the login body.
|
||||
|
||||
```http
|
||||
POST /api/identity/v1/session { login, password }
|
||||
-> { accessToken (short JWT), refreshToken (rotating opaque), expiresAt,
|
||||
mustChangePassword, user: { id, login, displayName, roles[], tenantId } }
|
||||
POST /api/identity/v1/session/refresh
|
||||
DELETE /api/identity/v1/session
|
||||
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
|
||||
GET /api/identity/v1/session/permissions
|
||||
```
|
||||
|
||||
Errors: `400` malformed; `401 INVALID_CREDENTIALS` (one generic message for unknown login and wrong password); `403 TENANT_DISABLED` / `TENANT_MISMATCH`; `429 RATE_LIMITED` with `Retry-After`. While `mustChangePassword` is true, every non-auth admin endpoint returns `403 PASSWORD_CHANGE_REQUIRED`. Provisioning: random one-time bootstrap password (never `{slug}2026$`), store only an Argon2id hash with a unique salt, never log passwords/refresh tokens/authorization headers/session ids, rate-limit by tenant+login+source IP with backoff, rotate refresh tokens and revoke the full family on reuse, audit login success/failure + password change + refresh reuse + logout + lockout. nginx must preserve `proxy_set_header X-Storefront-Host $storefront_host; proxy_set_header Origin "";` and, for `Origin: https://admin.gorbushka.market`, resolve `$storefront_host` to `gorbushka.market`; API upstream stays `https://127.0.0.1:445`.
|
||||
|
||||
### 1.3 Infrastructure state (dev server `213.21.246.138`, user `seto`)
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| nginx 1.24 | Running. Proxies `/api/` → `127.0.0.1:8080`; `/health` = `ok`. |
|
||||
| Backend on :8080 | **Not running.** `/api/` currently 502s. |
|
||||
| PostgreSQL | Installed, inactive. Needs db, user, schema. |
|
||||
| `@marketplaces/auth` | Installs over plain git, no credentials. |
|
||||
| TLS / certbot | **Not installed.** Plain HTTP today. Multi-tenant needs per-domain or wildcard certs. |
|
||||
| DNS / subdomains | **Not set up.** No domain points at the server. Phase-equivalent target in §11. |
|
||||
| Frontend CD | Push to `main` deploys nothing today; `deploy.yml` exists (§13). |
|
||||
|
||||
Host hardening (sshd, fail2ban, sysctl) **is** applied on the frontend deploy — see `../DEPLOYMENT.md` §3.2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Release invariants (the gate)
|
||||
|
||||
A release that violates any one of these does not ship. Each is falsifiable; the acceptance tests are in §15.
|
||||
|
||||
1. Public tenant is determined by verified `Host` alone. No public endpoint accepts a `marketplaceId` from the browser.
|
||||
2. The price of an order is computed by the backend. A price in a request is ignored, never validated-and-used.
|
||||
3. Stock and reservation change atomically — two buyers racing for the last unit produce exactly one payable order.
|
||||
4. Payment creation and webhook receipt are idempotent, enforced by unique constraints, not handler logic.
|
||||
5. Provider credentials never leave the backend — not in a response, not in a bundle, not in a log.
|
||||
6. A published revision is immutable. Rollback creates a new revision; history is never rewritten.
|
||||
7. Rolling back design does not roll back live inventory, orders, or payments.
|
||||
8. No user reads a marketplace they are not assigned to — through the UI or a direct API call.
|
||||
9. Every administrative mutation leaves an audit record: actor, action, before, after.
|
||||
|
||||
---
|
||||
|
||||
## 3. How work lands
|
||||
|
||||
**One functional area per PR.** Each carries: purpose, screenshots (where UI), API changes, migrations, test evidence, security impact, rollback plan. Never change a payment/inventory/order state machine in the same PR as a redesign.
|
||||
|
||||
**Migrations are expand/contract.** The expand step must be deployable on its own.
|
||||
|
||||
**A release is not "the build passed."** Each records version, migrations applied, healthcheck, post-deploy smoke, dependency audit, and the rollback path. Audit coverage (invariant 9) is a property every mutating endpoint carries from its first line, not a step.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-cutting mechanisms
|
||||
|
||||
### 4.1 Money
|
||||
|
||||
All money is minor units, never float. `Money { amountMinor: number; currency: string }` (ISO 4217). RUB/USD/EUR/AMD are 2-decimal. Conversion rounds half-up to the currency's minor-unit precision, once, at the point of conversion — never re-rounded on redisplay.
|
||||
|
||||
### 4.2 Sessions (FH-2.3)
|
||||
|
||||
- Token = 32 random bytes, stored as **SHA-256 hash only** — a DB read yields no usable credential.
|
||||
- `HttpOnly; Secure; SameSite`; revocable; rows carry `expiresAt`/`revokedAt`/`ip`/`userAgent`. Admin sessions 12 h, customer sessions 30 days.
|
||||
- **One cookie name per contour** — `bo_session` / `manager_session` / `marketplace_session`. A customer session must never satisfy an admin guard; the guarantee is different cookies checked by different guards.
|
||||
- Validation rejects on: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not enrolled.
|
||||
- Password change revokes every live session for the user **in the same transaction** as the password write.
|
||||
- Credentials: Argon2id `memoryCost 65536, timeCost 3, parallelism 1`, ≥16 chars. TOTP **mandatory** for every platform/marketplace role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token + `otpauth://` URI and issues no session until confirmed. The enrolment token grants nothing else.
|
||||
|
||||
### 4.3 Origin allowlist (FH-2.4)
|
||||
|
||||
One hook ahead of routing: any non-`GET`/`HEAD`/`OPTIONS` on `/api/admin/*`, `/api/platform/*`, `/api/manager/*` whose `Origin` is not allowlisted → `403`, before the handler. CORS uses the same allowlist with `credentials:true` — never `*`, never reflected. The allowlist is per-environment configuration.
|
||||
|
||||
### 4.4 Encrypted secret envelope (FH-2.9)
|
||||
|
||||
Stored credentials use `v1.<iv>.<authTag>.<ciphertext>` base64url, AES-256-GCM, 12-byte random IV per value, 32-byte key from env/secret-manager. The version tag lets the algorithm rotate. Decrypt only inside the using service — never on a DTO, in a log, or in any response (including to a `PLATFORM_OWNER`; backoffice shows presence, last-rotated, and an HMAC fingerprint, not the value). Fingerprints are `HMAC-SHA256(key, value)`. Redirect/callback URLs are built backend-side from the verified domain and allowlisted; the browser receives a URL to navigate to, never the material to build one. Covers payment credentials, connector credentials, bot tokens, FX keys, per-tenant OAuth secrets.
|
||||
|
||||
### 4.5 RoutingContext (Partner §7, on every payment)
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf
|
||||
leafNodeId: string; // the payment point money is accepted at
|
||||
environment: 'TEST' | 'LIVE';
|
||||
merchantReference: string; // partner-supplied, opaque, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
Required on `CheckoutSession`, `PaymentIntent`, `Payment`, and every refund/reconciliation/settlement row. Resolved and **frozen at checkout-session creation**, immutable for the payment's life. `routingPath` must resolve to exactly one leaf or the payment is rejected at creation (never accepted and resolved during reconciliation). A payment whose leaf is `suspended`/`disabled` is rejected. `environment` must match the credential's or `403`. **Carry it from the first payment row — retrofitting it onto a populated table is far more expensive.**
|
||||
|
||||
### 4.6 RBAC (Track S)
|
||||
|
||||
17 roles, 3 scopes:
|
||||
|
||||
```ts
|
||||
type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
|
||||
type MarketplaceRole = 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER'
|
||||
| 'ORDER_MANAGER' | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
|
||||
type SellerRole = 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER'
|
||||
| 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
|
||||
```
|
||||
|
||||
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint checks `(role, tenantScope)` against the session **before** touching data. A `MARKETPLACE_ADMIN` for A querying B's data gets `403`, not an empty result. `GET /api/identity/v1/session/permissions -> { role, scopes[], marketplaceIds[] }` is what frontend guards derive from — never hardcode role logic client-side beyond hiding affordances.
|
||||
|
||||
**Step-up auth** required before: bank/payment detail changes, production launch, role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, any manual financial override. **PII minimization:** exposed only to roles that need it for scope; export endpoints are themselves audited.
|
||||
|
||||
**Bootstrap admin & self-service** (§8 of old Track S): each marketplace ships one bootstrap `MARKETPLACE_ADMIN` — `login` = marketplace slug, `password` = a cryptographically random one-time secret delivered out of band (never derived from the slug), `mustChangePassword: true`; login succeeds but every non-auth request `403`s with `PASSWORD_CHANGE_REQUIRED` until changed. `POST /api/identity/v1/session/change-password`. A `MARKETPLACE_ADMIN` provisions sub-admins scoped to its own tenant via `POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }` (+ `GET/PATCH/DELETE /team`); `role` must be a `MarketplaceRole` (platform-scope → `403 SCOPE_ESCALATION_DENIED`), `marketplaceId` is forced server-side to the caller's scope, every change audited, `MARKETPLACE_ADMIN` grants require step-up.
|
||||
|
||||
### 4.7 Audit log
|
||||
|
||||
```ts
|
||||
interface AuditEvent {
|
||||
id: string; actor: string; action: string; // 'role.changed', 'offer.price_updated', 'refund.approved'
|
||||
entityType: string; entityId: string;
|
||||
before?: unknown; after?: unknown; reason?: string; occurredAt: string; ip?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Mandatory coverage: permission changes, seller status changes, catalog moderation, price changes, payment/refund actions, manual order overrides, credential changes, launch actions. `GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=`.
|
||||
|
||||
### 4.8 Rate limiting
|
||||
|
||||
`429 { error: { code: 'RATE_LIMITED', retryAfterSeconds } }` on storefront/auth/provider endpoints. Partner limits are per `partnerId` by tier, published in the OpenAPI so a partner reads its limit rather than discovering it via `429`.
|
||||
|
||||
### 4.9 Order-manager contour (FH-2.14)
|
||||
|
||||
`ORDER_MANAGER` is a **separate surface**, not a narrower menu: own URL, shell, login, and session cookie; a manager hitting a backoffice URL gets `403` from the guard. Scope from **membership rows, never configuration**. Catalog, design, domains, payment settings, platform users refuse — not merely hidden. PII masked in lists, revealed in detail only with permission, reveal and export audited.
|
||||
|
||||
---
|
||||
|
||||
## 5. Money, FX, payment state machine (Phase 1)
|
||||
|
||||
**Why:** rates are typed into `localStorage` and drift; the charged `amount` is computed client-side and trusted; nothing records which FX rate produced a price. Bank/NSPK totals can't reconcile.
|
||||
|
||||
**FX quote.** `GET /api/v2/pricing/fx-quote?base=RUB"e=USD` → `{ quoteId, base, quote, rate, source, observedAt, expiresAt }`. `rate` may be float (market rate, not money). The frontend must re-fetch past `expiresAt`. If the source is down, the backend either blocks (`503 FX_SOURCE_UNAVAILABLE`) or serves a `"source":"fallback"` quote — a tenant setting. FX source is **ours, in-house, as the default** (`source: "internal"`); no external provider committed.
|
||||
|
||||
**PriceSnapshot.** Created once at checkout, immutable. `{ id, offerId, amount, displayAmount, fxQuoteId, capturedAt }`. Never recalculated — an old order shows the price it was actually charged.
|
||||
|
||||
**Server-authoritative amount (highest priority).** Replace client-trusted `POST /cart {amount, items[{price}]}` with:
|
||||
|
||||
```
|
||||
POST /api/v2/storefront/checkout { offers: [{offerId, qty}], currency, deliveryOptionId }
|
||||
```
|
||||
|
||||
The frontend sends offer ids + quantities only; the backend computes every price from the live offer price and current FX quote. **No `amount`/`price` is ever accepted from the client for anything affecting the charge.** `POST /api/v2/storefront/payments/intents` references `checkoutSessionId` only. Total = `sum(unitPrice*qty) − discounts + delivery + taxes/fees`, reconstructable per line for backoffice.
|
||||
|
||||
**Payment state machine.**
|
||||
```
|
||||
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
|
||||
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
|
||||
Order: pending_payment -> paid -> processing -> fulfilled/completed
|
||||
```
|
||||
`PaymentEvent { id, paymentIntentId, fromState, toState, providerEventId, providerTimestamp, receivedAt, processedAt }`. No fixed delays anywhere. Webhook: `POST /api/providers/v1/payments/{provider}/webhook` — signature mandatory (`401` on fail), idempotency key `provider + providerEventId`, on success emit `payment.confirmed`/`payment.failed` onto the bus so order creation is event-driven. Idempotent order creation: `POST /api/admin/v2/orders` (internal) with `Idempotency-Key: <checkoutSessionId>` returns the existing order on retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cart & checkout (Phase 6)
|
||||
|
||||
Server-owned cart from add-to-cart onward (today it's `localStorage` + Telegram CloudStorage; `features/website/checkout/` is empty).
|
||||
|
||||
```ts
|
||||
interface Cart { id; marketplaceId; customerId?; sessionToken?; createdAt; expiresAt }
|
||||
interface CartLine { id; cartId; offerId; qty; addedAt } // never a client price
|
||||
interface CheckoutSession { id; cartId; customerContact:{email?,phone?,verified}; deliveryOptionId; status:'open'|'confirmed'|'expired'; createdAt; expiresAt }
|
||||
interface DeliveryOption { id; marketplaceId; label; price: Money; type:'pickup'|'courier'|'digital' }
|
||||
```
|
||||
|
||||
```
|
||||
POST /api/v2/storefront/cart/lines { offerId, qty }
|
||||
PATCH /api/v2/storefront/cart/lines/{id} { qty }
|
||||
DELETE /api/v2/storefront/cart/lines/{id}
|
||||
GET /api/v2/storefront/cart
|
||||
```
|
||||
|
||||
Idempotent mutations; qty validated against Offer/Inventory on **every** mutation. Guest cart by `sessionToken`, merges into the customer cart on login (never drops items). Inactive carts and their reservations clear on `expiresAt`. **Price-refresh:** `GET /cart` returns captured price + current price + `priceChanged` when an offer's price moved; the frontend must confirm before checkout, the backend must expose the comparison, never silently pick one. Checkout reads the server cart directly; contact requirement and guest-checkout allowance are per-tenant policy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payments, reconciliation, refunds, settlements (Phase 7)
|
||||
|
||||
**Idempotency as constraints (FH-2.2).** `UNIQUE(payment.idempotency_key)` and `UNIQUE(payment_webhook_event.provider, event_key)`.
|
||||
- Payment create requires `Idempotency-Key`; same key + same order → return existing, + different order/marketplace → `409`.
|
||||
- Webhook: insert the event row **first**; a unique-violation is the duplicate signal → `{accepted:true, duplicate:true}`, stop. Only a successful insert applies the status change; set `processedAt` after applying (a crash between insert and apply shows as unprocessed, not lost). `event_key` = provider event id, else `sha256(rawBody)`. **Signature verified against the raw body** before any parse.
|
||||
- Poll as reconciliation, not primary: a scheduled job re-checks provider status for payments still `pending` in the last 24 h and applies through the same state-machine path; transient failures swallowed, next tick retries. No fixed delay, no UI-driven poll standing in for a missed webhook.
|
||||
|
||||
**Refunds.** `Refund { id, orderId, orderLineIds[], amount, reason, actor, status:'requested'|'approved'|'processing'|'completed'|'failed', requestedAt, completedAt?, routing }`. Routing is **copied verbatim** from the original payment, never re-resolved — a store suspended after payment is still refundable. `POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }`, `GET` same. Updates `Payment.status` to `refunded`/`partially_refunded`, emits `refund.requested`/`refund.completed`.
|
||||
|
||||
**Reconciliation.** `ReconciliationRecord { id, orderId, providerPaymentId?, internalAmount, providerAmount?, matchStrategy:'provider_payment_id'|'merchant_reference'|'amount_currency_fallback', result:'matched'|'unmatched'|'duplicate'|'amount_mismatch'|'status_mismatch', resolvedBy?, resolvedAt?, resolutionNote?, routing }`. Match by providerPaymentId → merchant reference → amount+currency; surface non-matched in backoffice with audited resolution. `GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result=`, `POST /{id}/resolve {note}`.
|
||||
|
||||
**Settlements.** `Settlement { id, sellerId, periodStart, periodEnd, grossAmount, commission, refunds, netPayout, status:'pending'|'paid' }`. Seller split happens **after** routing: payment → routed to one payment point (frozen at checkout) → reconciled there → split across the sellers whose lines the order contains. A settlement belongs to one seller within one store; a seller in two stores gets two settlements. Splitting never rewrites RoutingContext. `grossAmount` across a store's settlements must reconcile against that store's matched rows for the period. `GET /api/seller/v1/finance/settlements`, `GET /api/admin/v2/finance/settlements?...`.
|
||||
|
||||
**Provider breadth:** QR + card today via one integration; the `PaymentIntent`/`Payment` shapes are provider-agnostic, so wallets/BNPL are a new adapter behind the same state machine — an open business decision, no action until made.
|
||||
|
||||
---
|
||||
|
||||
## 8. Catalog, offers, inventory, fulfillment (Phase 3)
|
||||
|
||||
**Two-layer split.** `Product` (content) vs `Offer` (one seller's proposition). One product, many offers.
|
||||
|
||||
```ts
|
||||
interface Product { id; marketplaceId; categoryId; brand?; title; description; attributes; media[]; status:'draft'|'moderation'|'published'|'paused'|'archived' }
|
||||
interface Variant { id; productId; sku; barcode?; optionValues; dimensions? }
|
||||
interface Category { id; marketplaceId; parentId|null; slug; attributesSchema; order; seo }
|
||||
interface Offer { id; marketplaceId; sellerId; variantId; sellerSku; price: Money; stockPolicy:'track'|'no_track'|'preorder'; status:...; publishedAt?; executabilityChecked }
|
||||
interface PriceHistory { offerId; price: Money; changedBy; changedAt }
|
||||
```
|
||||
|
||||
**Inventory** `{ offerId, available, reserved, sold, warehouse?, source }` + `StockReservation { id, offerId, qty, reason:'checkout'|'pre_payment', expiresAt, released }`. available/reserved/sold counted separately, never derived. Feed updates are idempotent upserts. Oversell → dedicated incident queue, never silently hidden.
|
||||
|
||||
**Atomic reservation (FH-2.1).** Reserve with one conditional write:
|
||||
```sql
|
||||
UPDATE inventory SET reserved = reserved + :qty
|
||||
WHERE offer_id = :id AND (available - reserved) >= :qty RETURNING id
|
||||
```
|
||||
Zero rows → `409`, no retry, no partial reserve; a multi-line cart reserves every line in one transaction and rolls all back if any line returns zero. No `SELECT` before the `UPDATE`, no advisory lock — the `WHERE` clause is the concurrency control. TTL 15 min. Release and consume follow the same one-statement rule.
|
||||
|
||||
**Inventory journal (FH-2.8).** Every change writes one immutable `InventoryMovement { id, offerId, deltaAvailable, deltaReserved, deltaSold, reason, referenceType?, referenceId?, actor?, resultingAvailable, occurredAt }`. Never updated/deleted; a correction is a new compensating row. `resultingAvailable` recorded at the time; replaying the journal reproduces the record exactly. A manual adjustment without `actor` is rejected.
|
||||
|
||||
**Publish-time executability.** An offer that can't be fulfilled must not publish: valid `Fulfillment` type, stock policy `track` with `available>0` or `no_track`/`preorder`, required category attributes present. This is what makes "no branch distinguishes a buyer from an inspector" true.
|
||||
|
||||
**Digital code pools (FH-2.11).** `FulfillmentMode: manual | code_pool`. `DigitalCode { id, marketplaceId, offerId, encryptedValue, valueHash, status:'available'|'reserved'|'assigned'|'revoked', orderLineId?, createdAt, assignedAt? }`. `valueHash` unique per `(marketplace, offer)` — importing a code twice is refused by the DB. `available` for a code_pool offer derives from the count of available codes. Moves `available→reserved` under the FH-2.1 write, `reserved→assigned` only on confirmed payment. **A code is returned to the browser only when the order is `paid`/`processing`/`fulfilled`** — earlier states return an empty code list. Revocation is terminal and audited.
|
||||
|
||||
**Bulk import.** `POST /api/admin/v2/products/bulk-import` (CSV multipart or JSON array) returns a validation-error **preview**; a separate `POST .../bulk-import/{importId}/apply` commits. Idempotent by SKU/external key (FH-2.15) — re-run updates, never duplicates; a row-level error never publishes a partial result; rollback-able only while none of its products have appeared on a paid order, then archive.
|
||||
|
||||
**Endpoints.** `GET/POST/PATCH /api/admin/v2/products[/{id}]`, `GET/POST/PATCH /api/admin/v2/offers[/{id}]`, `POST /api/admin/v2/offers/{id}/publish` (runs executability, `422 details[]` on fail), `GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId=`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Orders, events, notifications (Phase 2)
|
||||
|
||||
**One `Order` per checkout**, regardless of seller count; lines group into per-seller `Fulfillment`. No parent/child splitting. A seller sees only their `Fulfillment` group and their `OrderLine`s.
|
||||
|
||||
```ts
|
||||
interface Order { id; marketplaceId; source:'storefront'|'external'|'backoffice'|'api_partner'; externalOrderRef?; customerId?; currency; subtotal; discount; delivery; total: Money; paymentStatus; orderStatus; createdAt; paidAt? }
|
||||
interface OrderLine { id; orderId; offerId; sellerId; skuSnapshot; titleSnapshot; qty; unitPrice; lineTotal: Money; priceSnapshotId }
|
||||
interface Fulfillment { id; orderId; sellerId; type:'manual'|'warehouse'|'pickup'|'digital'; status:'pending'|'assigned'|'in_progress'|'issued'|'shipped'|'cancelled'; assignedTo?; issuedAt?; shippedAt?; evidence? }
|
||||
interface OrderEvent { id; orderId; type:'created'|'paid'|'seller_notified'|'accepted'|'fulfilled'|'cancelled'|'refunded'; actor?; occurredAt; metadata? }
|
||||
interface OrderContactSnapshot { orderId; name; email?; phone?; preferredChannel?; capturedAt } // immutable
|
||||
```
|
||||
|
||||
**Public token + snapshot completeness (FH-2.13).** `Order.publicToken` ≥24 random bytes, base64url, unique; **every customer-facing route addresses an order by it, never by `id`** (a sequential id turns "check my order" into enumeration). `GET /api/v2/storefront/orders/{publicToken}` is tenant-scoped; a valid token from another marketplace → `404`. `OrderLine` snapshots everything that must survive a later edit — currency, per-line discount, delivery option and price, tax/fee components — written once at creation, never updated in place; a correction is a new event/refund/amendment.
|
||||
|
||||
**Endpoints.** `GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=`, `GET /{id}`, `PATCH /{id}/status`, `POST /{id}/refund-request`, `POST /{id}/notes`, `POST /{id}/archive|restore`, `DELETE /{id}`. `GET /api/seller/v1/orders` returns only the authenticated seller's fulfillment groups and lines.
|
||||
|
||||
**Event bus.** `order.created|paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested|completed`, `external_order.imported`. Backend owns the implementation. Contract: `order.paid` **always** produces a backoffice notification even if every external channel is down.
|
||||
|
||||
**Notification Center.** `Notification { id, marketplaceId, entityType, entityId, severity:'info'|'warning'|'critical', eventType, read, deepLink, createdAt }` + `DeliveryAttempt { notificationId, channel, status:'sent'|'failed', error?, attemptedAt }`. `GET /api/admin/v2/notifications?...`, `PATCH /{id}/read`. A `DeliveryAttempt` failure never prevents the `Notification` row from being created and visible.
|
||||
|
||||
---
|
||||
|
||||
## 10. Identity & messaging (Phase 8)
|
||||
|
||||
Customer identity providers: VK ID and Yandex ID (OAuth), Telegram and MAX (bot/QR). **Frontend is built and tested** — provider-agnostic gateway, VK/Yandex login buttons, and the account-linking screen all exist; what's left is backend + the FH-0.1 decision.
|
||||
|
||||
**Provider-agnostic surface (FH-4.1/4.2).**
|
||||
```
|
||||
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
|
||||
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
|
||||
```
|
||||
`/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` **single-use for 10 min**, returns/302s to the provider with `code_challenge` (S256). `/callback` validates `state`, exchanges the code with the stored verifier, links the identity, issues the session cookie, redirects to a `returnTo` validated against the tenant origin. **The client never sees a secret, token, or verifier** — we are a confidential client, the backend owns PKCE. Unknown/expired/replayed `state` → generic error.
|
||||
|
||||
**`ExternalIdentity` (FH-4.3).** `{ customerId, provider:'vk_id'|'yandex_id'|'telegram'|'max', providerUserId, email?, phone?, displayName?, verifiedAt, lastUsedAt }`. `UNIQUE(provider, providerUserId)`; a provider account already bound to a *different* customer is an identity conflict routed to controlled resolution — never a silent rebind, enforced by the index. Email optional (VK often returns none). Per-tenant OAuth app config `{ clientId, clientSecret, scopes[], redirectUri }` stored under the §4.4 envelope.
|
||||
|
||||
**VK ID (FH-4.4).** OAuth 2.1, PKCE mandatory. Authorize `id.vk.com/authorize`, token `POST id.vk.com/oauth2/auth`, profile `POST id.vk.com/oauth2/user_info`, logout on unlink. **The callback returns `device_id` alongside `code` and the token exchange fails without it** — the most common integration bug.
|
||||
|
||||
**Yandex ID (FH-4.5).** OAuth 2.0 + PKCE. Authorize `oauth.yandex.ru/authorize`, token `POST oauth.yandex.ru/token` (HTTP Basic `client_id:client_secret`), profile `GET login.yandex.ru/info?format=json` (`Authorization: OAuth <token>`). A second strategy on the same surface; build after VK.
|
||||
|
||||
**Telegram → identity (FH-4.6).** A Telegram login writes an `ExternalIdentity` (`provider:'telegram'`) under the same uniqueness/conflict rule; appears in `/me/identities`, unlinkable subject to the **last-identity `409`** (never remove a customer's only login). Keep customer (`marketplace_session`) and admin (`bo_session`) sessions as distinct cookies — closes the shared customer/admin session finding. The identity row and the messaging `BotConversationBinding` stay separate records.
|
||||
|
||||
**Email/phone OTP (FH-4.8).** Recovery when a linked messenger is unreachable and an addable second factor — never the primary login; one more identity/contact on the same customer, not a parallel account. Implements the existing `../superpowers/specs/2026-08-15-email-phone-login-design.md`.
|
||||
|
||||
**MAX + Telegram bot channels.** `BotConversationBinding { customerId, marketplaceId, provider:'telegram'|'max', chatId, state, orderId?, lastMessageAt }`. MAX linking: `POST /api/identity/v1/max/link-code -> { code, expiresAt }` (single-use, bound to marketplace + browser session); user sends the code to the bot; `POST /api/providers/v1/max/bot-webhook` (idempotent) links the session. All providers' bot updates normalize to `MessagingEvent { provider, chatId, orderId?, text?, receivedAt }`. Bot tokens never reach the frontend.
|
||||
|
||||
**Notification Orchestrator + delivery conversation.** `order.paid` routes to the customer's chosen channel; the backoffice notification always fires even if the messenger is down. The bot never changes financial statuses — it writes delivery-detail fields via a dedicated service only. Follow-ups rate-limited, then hand off to a human. `POST /api/providers/v1/{provider}/bot-webhook`, `GET /api/admin/v2/orders/{orderId}/conversation`, `POST /{orderId}/conversation/handoff`.
|
||||
|
||||
**Blocking decision — FH-0.1.** VK and Yandex validate `redirect_uri` against an exact registered list; a multi-tenant platform can't register one per tenant domain. Resolution to confirm: one **central identity host** as the sole registered callback, tenant carried in the signed `state`, a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for the session cookie. Also decide: one VK account across two storefronts — one `Customer` or two? (`Customer.marketplaceId` implies two, the safer default.) Record both in an ADR before any identity code.
|
||||
|
||||
---
|
||||
|
||||
## 11. Tenant registry, domains, publish (Phase 9)
|
||||
|
||||
**Hierarchy** (Company → Project → Marketplace → PaymentPoint; see §12 partner API). `Company`/`Project` are thin ownership/scope nodes; all config stays on `Marketplace`.
|
||||
|
||||
```ts
|
||||
interface Marketplace { id; companyId; projectId; externalReference?; name; code; type:'commerce'|'mall_directory'|'hybrid'|'single_brand'; ownerId; countries[]; locales[]; currencies[]; timezone; lifecycleState }
|
||||
type MarketplaceLifecycleState = 'draft'|'configured'|'content_ready'|'domains_planned'|'staging_live'|'qa_passed'|'production_ready'|'live'|'paused'|'archived';
|
||||
interface MarketplaceDomain { marketplaceId; domain; type:'production'|'www'|'staging'|'preview'|'api'|'seller'; status:'planned'|'dns_pending'|'ssl_pending'|'active'|'failed' }
|
||||
interface MarketplaceFeatureSet { marketplaceId; features: Record<string,boolean> }
|
||||
interface MarketplaceRevision { id; marketplaceId; status:'draft'|'validated'|'preview'|'published'; publishedAt?; supersedesRevisionId? }
|
||||
interface PaymentPoint { id; marketplaceId; method:'qr'|'card'; currencies[]; externalReference?; status; providerAccountRef?; createdAt; updatedAt }
|
||||
```
|
||||
|
||||
Creating a payment point registers the channel but does **not** enable real money (needs `providerAccountRef` via a separate flow). Backfill existing marketplaces: create a Company, a Project ("marketplaces"), set `companyId`/`projectId` on every marketplace, create PaymentPoints for existing methods, then make the fks non-nullable.
|
||||
|
||||
**Lifecycle.** `GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers[] }` (return the *specific* blocker), `POST .../lifecycle/advance`. **Onboarding wizard** — 8 steps: `POST /marketplaces` (name/code/type/owner/locales/currencies/timezone), `PATCH /{id}/feature-set`, `POST /{id}/domains`, `PATCH /{id}/design`, `POST /{id}/roles`, `PATCH /{id}/integrations`, `POST /{id}/staging-launch` (smoke tests), `POST /{id}/production-launch` (all P0 blockers closed + approval).
|
||||
|
||||
**Domain automation (Hostinger).** `GET/POST(validate)/PUT/DELETE /api/dns/v1/zones/{domain}`, `GET /snapshots/{domain}[/{id}]`, `POST /snapshots/{domain}/{id}/restore`. Order: read zone → **snapshot before any change** → build+validate plan → never touch MX/SPF/DKIM/DMARC/CAA without a scoped task → apply after approval → verify propagation/SSL/health → mark `active` only then.
|
||||
|
||||
**Publish model.** `draft → validation → preview → publish`. `POST /api/admin/v2/marketplaces/{id}/revisions`, `.../{revId}/validate|publish|rollback`.
|
||||
- **Immutability (FH-2.7):** `version = max(version)+1`, `UNIQUE(marketplaceId, version)`, materialized snapshot (a product renamed tomorrow doesn't change what was published today), `publishedRevision` pointer flipped in the publishing transaction, rollback writes revision *n* as *max+1* (history only grows). **Operational state — inventory, reservations, orders, payments — never travels with a revision.**
|
||||
- **Clone (FH-2.7):** carries theme/sections/pages/navigation/category tree/collections/offer assignments; **never** carries domains/admin users/customers/sessions/orders/payments/credentials/webhook secrets/audit. Inventory starts at zero unless a platform role opts otherwise. Category walk is topological with cycle detection (`400` naming the cycle).
|
||||
- **Preview (FH-2.6):** `POST .../{id}/preview-token -> { url, expiresAt }`. HMAC over `{marketplaceId, expiresAt, nonce}`, 15-min TTL, `storefront_preview` HttpOnly cookie, constant-time compare, invalid/expired → `404` (an unpublished storefront doesn't confirm its existence). **While the preview cookie is present, every non-`GET` on the public API → `404`** (hook ahead of routing). Responses carry `X-Robots-Tag: noindex, nofollow`.
|
||||
|
||||
**Tenant resolution (FH-2.5).** `GET /api/v2/storefront/bootstrap` resolves server-side from verified `Host`. Normalize: lowercase, strip trailing dot, strip port, then match a unique `hostname` row — resolve only once `verifiedAt` is set and the marketplace serves. Brief cache (~30 s) with **explicit invalidation** on domain add/verify/remove and state change. `Host` read from the trusted proxy chain (proxy overwrites the client value). **No public endpoint accepts `marketplaceId`.** Unknown/unverified host → `404`, no fallback tenant.
|
||||
|
||||
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every ledger row are not part of a revision.
|
||||
|
||||
---
|
||||
|
||||
## 12. Sellers · connectors · content · analytics · partner API
|
||||
|
||||
### 12.1 Seller portal (Phase 5)
|
||||
|
||||
A seller never owns a separate `Order` — they see their `Fulfillment` groups and `OrderLine`s within shared orders, pre-filtered server-side (never trust a frontend `sellerId`).
|
||||
|
||||
```ts
|
||||
interface SellerOrganization { id; marketplaceId; legalName; status:'pending'|'approved'|'suspended'|'rejected'; bankDetailsRef; createdAt }
|
||||
interface SellerUser { id; sellerOrganizationId; role: SellerRole; email; status:'active'|'invited'|'suspended' }
|
||||
interface SellerMarketplaceMembership { sellerOrganizationId; marketplaceId; status }
|
||||
interface SellerIntegration { sellerOrganizationId; apiCredentialRef; webhookUrl?; lastSyncAt?; lastSyncError? }
|
||||
```
|
||||
|
||||
`POST /api/seller/v1/onboarding`, `GET /profile`, `GET/POST/PATCH /offers`, `POST /offers/bulk-price-update`, `GET /orders`, `PATCH /orders/{orderId}/fulfillment/{fulfillmentId}`, `GET /finance/accruals|settlements`, `POST /finance/bank-details` (step-up + audit, optional maker/checker), `GET /team`, `POST /team/invite`, `GET /integrations`. Every endpoint enforces `SellerUser.role` server-side; the query layer carries an implicit `WHERE sellerOrganizationId = :authenticatedSeller` — a seller can never reach another seller's data by parameter manipulation.
|
||||
|
||||
### 12.2 Connectors — external order ingest (Phase 4)
|
||||
|
||||
A new partner connector is an onboarding action, not a code change. Fixed shared pipeline: ingest → verify/auth → persist `RawExternalEvent` **before parsing** → normalize to a canonical shape → map `externalSku → Offer` (no mapping → Unmatched queue, never silent) → create/update order (`source:'external'`) → emit events → push status back if supported.
|
||||
|
||||
```ts
|
||||
interface Connector { id; marketplaceId; provider; authType:'webhook_signed'|'api_key'|'oauth2'; credentialRef; pollingIntervalSeconds?; cursorState?; status:'active'|'paused'|'error' }
|
||||
interface RawExternalEvent { id; connectorId; payload; receivedAt; processedAt? }
|
||||
interface ExternalOrderMapping { connectorId; externalSellerId; externalProductId; externalSku; internalSellerId; internalOfferId }
|
||||
interface DeadLetter { id; connectorId; rawEventId; reason; retryCount; lastAttemptAt; resolvedAt? }
|
||||
interface ExternalOrderEvent { connectorId; externalOrderId; externalCreatedAt; customer; lines[{externalSku,qty,unitPriceMinor,currency}]; totalMinor; currency; rawEventId }
|
||||
```
|
||||
|
||||
Idempotency key = `connectorId + externalOrderId/eventId`; **zero duplicate orders on repeated delivery**. `POST /api/providers/v1/{connector}/webhook`, `GET/POST/PATCH /api/admin/v2/integrations[/{id}]`, `GET /{id}/unmatched`, `POST /{id}/unmatched/{eventId}/resolve`, `POST /{id}/dead-letter/{id}/replay`. SLA: webhook 99% under 60 s; polling delay ≤ `interval + 60`; every error carries a trace id.
|
||||
|
||||
### 12.3 Content modules — mall-class tenants (Phase 10)
|
||||
|
||||
Lowest priority, only after commerce core is real. Entities (all carry `marketplaceId`, audit, and the §11 draft/publish flow): `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `Lead`, `NewsPromo`, `MallSettings`. `GET/POST/PATCH/DELETE /api/admin/v2/content/{shops|shop-categories|services|floors|scheme-pins|rent-listings|news}`, `POST /content/rent-listings/{id}/leads`, `PATCH /content/mall-settings`. Commerce modules are platform-ready but off via `MarketplaceFeatureSet` — the point is proving a tenant flips `catalog`/`cart`/`checkout` to `true` later with zero code change.
|
||||
|
||||
**Server-side content validation (FH-2.10).** The server re-runs the editor's rules on write. Clamp-and-fallback: clamp out-of-range numbers, fall back an invalid colour, blank a URL that isn't a same-origin path or `https://`, trim/truncate text. Structural violations (unknown block type, malformed id, too many blocks/ids) → `400`. Limits published as one schema both sides read. Referential checks (block → deleted category / unpublished offer) are publish blockers unless a fallback is declared.
|
||||
|
||||
### 12.4 Analytics (Track A) — start early, longest lead time
|
||||
|
||||
`AnalyticsEvent { eventType, marketplaceId, sessionId, customerId?, timestamp, properties, isSynthetic }`. `POST /api/v2/storefront/analytics/events`. Backend is source of truth for `sessionId` and `isSynthetic` — **never trust a client synthetic flag.** Vocabulary: traffic (`session_started`, `page_view`, `product_view`), catalog (`search`, `category_view`, `seller_view`), commerce (`add_to_cart`, `checkout_started`, `payment_started|success|failed`, `order_created`) — emitted from the same code paths that produce `PaymentEvent`/`OrderEvent`, not a drifting parallel layer. `OperationalMetric` for latencies/lag. **Synthetic traffic** is staging/demo only, `isSynthetic:true` set server-side by environment/token — reports filter it by construction. `GET /api/admin/v2/analytics/funnel|operational|quality`, `GET /api/v2/storefront/search/trending`.
|
||||
|
||||
### 12.5 Partner provisioning — inbound (`/api/partner/v1/`)
|
||||
|
||||
Partners provision their own merchant hierarchy, then payments route back to the correct leaf. **Deliberately generic** — no partner name in any entity/field/endpoint; partner-specific behaviour lives in a `PartnerProfile` config row.
|
||||
|
||||
Four fixed levels `Company → Project → Store(=Marketplace) → PaymentPoint`; middle levels optional per profile. `ProvisioningNode { id, level, parentId, companyId, path[], environment:'TEST'|'LIVE', status:'active'|'suspended'|'disabled', externalReference, displayName, ... }`. `path` is server-computed; nodes never re-parent (move = disable + create); `disable` cascades terminally, `suspend` cascades reversibly by cascade id; creating a node never enables money. `TEST`/`LIVE` are a hard partition (cross-env → `403`).
|
||||
|
||||
Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/payment-points`, `PATCH /nodes/{id}/status`, `POST /nodes/{id}/disable`. Read: `GET /nodes/{id}`, `/companies/{id}/hierarchy`, `/nodes/lookup?externalReference=`, `/companies/{id}/audit`. Every `POST` needs `Idempotency-Key` (scope `(partnerId, endpoint, key)`, 24 h, same key+body → replay, +different body → `409`, no partial hierarchy). **Signed requests** (ed25519/rsa-pss), private key never transmitted, ±5 min skew, nonce replay rejected; authority is the credential's `scopeNodeId` subtree, a credential can never widen its own scope. `POST/GET/rotate/DELETE /credentials`. Stable error codes (`validation_failed 422`, `scope_forbidden 403`, `environment_mismatch 403`, `node_disabled 409`, `signature_invalid 401`, …). Partner-facing serialization uses the partner's own field names via `PartnerProfile.routingFieldNames`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Infra, tenant routing, deploy
|
||||
|
||||
**Deterministic hostname rule.** One API hostname per base domain: `example.com`, `store1.example.com`, `www.example.com` all use `https://api.example.com`. Localhost is the only exception (local `/api` proxy).
|
||||
|
||||
**Backend must,** for every request on the shared `api.<base-domain>`: use `X-Storefront-Host` (nginx derives it from a validated browser `Origin`, sends it as upstream `Host`, keeps the shared API host in `X-Forwarded-Host`); not infer a subdomain tenant from the API `Host`; resolve the normalized storefront hostname through the domain registry; reject unknown/disabled/unverified domains with `403` before reading tenant data (never fall back to a default tenant); bind the session to the resolved tenant and reject a mismatch; trust `X-Storefront-Host`/`X-Forwarded-*` only from the known proxy; return JSON for `/bootstrap` with a tenant identity matching the domain (HTML or a default-tenant response is a fault).
|
||||
|
||||
**CORS.** Echo the exact validated storefront origin, `Access-Control-Allow-Credentials: true`, `Vary: Origin`, methods `GET,POST,PUT,PATCH,DELETE,OPTIONS`, headers `Authorization, Content-Type, AdminWebSessionID, X-Requested-With`, preflight `204`. Never `*` with credentials.
|
||||
|
||||
**nginx/TLS.** `scripts/deploy/configure-api-domain.sh --domain … --email … --upstream https://127.0.0.1:445` (idempotent, root) creates the shared `api.<domain>`, issues/renews its cert, configures CORS, proxies all paths. Subdomains need no extra API DNS/cert.
|
||||
|
||||
**CI/CD.** `deploy.yml` runs the same configurator before activating a frontend release. Secrets: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, `STOREFRONT_DOMAINS`, `CERTBOT_EMAIL`, `BACKEND_UPSTREAM`. One-time `server-setup.sh` installs the root-owned configurator and host hardening (`../DEPLOYMENT.md` §3.2).
|
||||
|
||||
**Structural DB isolation (FH-D.2).** Data network `internal: true`, API bound to loopback, `no-new-privileges` on every service. **Restore drill (FH-D.1):** WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) plus a scheduled restore-check that restores into a clean environment and records the result.
|
||||
|
||||
**Acceptance:** `curl -fsS https://api.example.com/bootstrap | jq -e 'type=="object"'` and an OPTIONS preflight both pass; the bundle contains no fixed marketplace API hostname; unknown domains `403`; API never returns the Angular `index.html` fallback.
|
||||
|
||||
---
|
||||
|
||||
## 14. Change log
|
||||
|
||||
Append here whenever a section changes. Newest first.
|
||||
|
||||
- **2026-08-22** — Added `published: boolean` to the bootstrap response contract (§1.1): frontend now renders a built-in generic placeholder (all feature flags on) for any marketplace with no published revision, decided from this one field rather than HTTP status. See [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md).
|
||||
- **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule; folded in the admin credential (login/password) auth handoff (§1.2). No contract content changed; the former per-phase files are removed.
|
||||
- **2026-08-21** — Harvest additions (`FH-*`) folded in across §4–§13, from the parallel-platform review ([ADR-0006](../context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md)): atomic reservation, inventory journal, idempotency constraints, session model, origin allowlist, secret envelope, order public token, revision immutability/clone/preview, tenant resolution hardening, server-side content validation, digital code pools, order-manager contour, provider-agnostic identity + VK/Yandex + Telegram migration, host hardening.
|
||||
- **2026-08-18** — RoutingContext + Company/Project/PaymentPoint hierarchy added (partner provisioning); backend ownership answered (separate developer).
|
||||
- **2026-08-17** — Payment chain freeze lifted (Sprint 0.1); FX source decided in-house.
|
||||
|
||||
---
|
||||
|
||||
## 15. Acceptance tests
|
||||
|
||||
Backend integration tests — the frontend can't prove a race or a replay against a mock.
|
||||
|
||||
| # | Scenario | Passes when | Guards |
|
||||
|---|---|---|---|
|
||||
| A1 | Two concurrent checkouts for the last unit | One payable order, one clean `409` | Inv. 3 |
|
||||
| A2 | Same provider webhook delivered twice | Order completes once, stock moves once, one notification | Inv. 4 |
|
||||
| A3 | A price sent in a checkout request | Ignored; charged amount is the server's | Inv. 2 |
|
||||
| A4 | Unknown/unverified `Host` | `404`, no other tenant's data | Inv. 1 |
|
||||
| A5 | `MARKETPLACE_ADMIN` for A queries B directly | `403`, not empty | Inv. 8 |
|
||||
| A6 | Cross-origin POST with a valid session cookie | Refused | §4.3 |
|
||||
| A7 | Any credential value searched for in responses/logs/bundle | Absent | Inv. 5 |
|
||||
| A8 | Rollback a design revision | Revision restored, live inventory untouched | Inv. 6–7 |
|
||||
| A9 | Mutation while a preview cookie is present | `404` | §11 |
|
||||
| A10 | Hand-crafted config the editor would reject | Refused | §12.3 |
|
||||
| A11 | Unpaid order requests its digital code | Empty code list | §8 |
|
||||
| A12 | Re-run the same import file | Updates, no duplicate | §8 |
|
||||
| A13 | Second VK login, same `providerUserId` | Same `Customer`, no duplicate | §10 |
|
||||
| A14 | VK account already bound to another customer | Conflict resolution, no silent rebind | §10 |
|
||||
| A15 | Unlink a customer's only identity | `409` | §10 |
|
||||
|
||||
---
|
||||
|
||||
## 16. Build order
|
||||
|
||||
1. **Launch gate (P0):** money model (§5) → orders/events (§9) → catalog/offers/inventory (§8) → connectors (§12.2). Track S (§4.6, §4.9) gates the launch — enforce it, nothing does today. Track A (§12.4) starts in parallel with §5 (longest lead time). Read the partner API (§12.5) before implementing §5 — it adds RoutingContext to the payment tables.
|
||||
2. **Publish & content:** §11 (preview/revision/clone/tenant hardening), §12.3 content validation.
|
||||
3. **Identity:** unblock FH-0.1, then §10 in order VK → Yandex → Telegram migration → OTP. Frontend already built.
|
||||
4. **Digital goods & manager contour:** §8 code pools, §4.9.
|
||||
5. **Continuous:** §13 ops.
|
||||
|
||||
---
|
||||
|
||||
## 17. Dev setup (day one)
|
||||
|
||||
1. Start/configure PostgreSQL; create db + user.
|
||||
2. Design the schema from these contracts (schema is the backend's own call; tenant scoping from day one).
|
||||
3. Build the API service on `127.0.0.1:8080` — nginx already proxies `/api/`.
|
||||
4. Implement the **bootstrap config endpoint** (§1.1) — without it the frontend can't render.
|
||||
5. Implement the Telegram session endpoints — login is fully built client-side, blocked only on these.
|
||||
6. Implement `GET /api/identity/v1/session/permissions` (§4.6) — frontend guards derive from it.
|
||||
7. Seed per-marketplace bootstrap admins (§4.6).
|
||||
|
||||
Steps 4–6 unblock the entire frontend.
|
||||
|
||||
---
|
||||
|
||||
## 18. Open decisions
|
||||
|
||||
- **FH-0.1** — central identity host + one-VK-account-across-storefronts (§10). Blocks identity.
|
||||
- Additional payment providers (wallets/BNPL) — new adapter, business decision (§7).
|
||||
- Per-connector adapters — written per partner at onboarding (§12.2).
|
||||
- Backfill of Company/Project/PaymentPoint for existing marketplaces — sequence in §11, not scheduled.
|
||||
- CI registry reachability (reverse proxy + TLS, or a different registry).
|
||||
@@ -1,823 +0,0 @@
|
||||
# Backend Surface Audit
|
||||
|
||||
Machine-oriented, exhaustive audit of every backend touch-point the Angular frontend
|
||||
expects — derived from the current source tree on branch `B2B`, not copied from prior
|
||||
docs. Purpose: single input for downstream backend-integration documentation tasks.
|
||||
|
||||
Legend for maturity (mirrors `docs/BACKEND_API.md`'s tagging so the two stay reconcilable):
|
||||
|
||||
- **LIVE** — real `HttpClient` call exists in code today (file cited).
|
||||
- **MOCK-SWAPPABLE** — interface + mock implementation exist, wired through an Angular
|
||||
DI token so a real `*Api*` class can be dropped in without touching UI. A real impl
|
||||
may or may not exist yet.
|
||||
- **MOCK-ONLY (no seam)** — mock/local implementation exists but the facade injects the
|
||||
concrete local class **directly** (no DI token). Adding a backend here first requires
|
||||
introducing a token seam. This is the single most important structural finding below.
|
||||
- **LOCAL-ONLY** — never talks to a backend by design (localStorage / in-memory /
|
||||
derived from already-loaded bootstrap). Listed for completeness.
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Executive summary & key findings](#1-executive-summary--key-findings)
|
||||
2. [Runtime provider strategy & environment](#2-runtime-provider-strategy--environment)
|
||||
3. [HTTP interceptor pipeline](#3-http-interceptor-pipeline)
|
||||
4. [Live HTTP endpoints (verified in code)](#4-live-http-endpoints-verified-in-code)
|
||||
5. [Domain: Auth (customer + admin)](#5-domain-auth-customer--admin)
|
||||
6. [Domain: Bootstrap / config / tenant](#6-domain-bootstrap--config--tenant)
|
||||
7. [Domain: Products & catalog](#7-domain-products--catalog)
|
||||
8. [Domain: Categories](#8-domain-categories)
|
||||
9. [Domain: Backoffice storefront data](#9-domain-backoffice-storefront-data)
|
||||
10. [Domain: Cart / orders / payments](#10-domain-cart--orders--payments)
|
||||
11. [Domain: Reviews & questions (engagement)](#11-domain-reviews--questions-engagement)
|
||||
12. [Domain: Location / regions](#12-domain-location--regions)
|
||||
13. [Domain: Widgets / dynamic renderer](#13-domain-widgets--dynamic-renderer)
|
||||
14. [Admin gateways (feature area)](#14-admin-gateways-feature-area)
|
||||
15. [Domain: Media library](#15-domain-media-library)
|
||||
16. [Domain: Content management / static pages](#16-domain-content-management--static-pages)
|
||||
17. [Domain: Project editor / builder](#17-domain-project-editor--builder)
|
||||
18. [Domain: Search](#18-domain-search)
|
||||
19. [Domain: User experience (wishlist/compare/etc.)](#19-domain-user-experience-wishlistcomparetc)
|
||||
20. [Domain: Diagnostics](#20-domain-diagnostics)
|
||||
21. [Facade catalog](#21-facade-catalog)
|
||||
22. [Gateway / provider master table](#22-gateway--provider-master-table)
|
||||
23. [Model / DTO catalog](#23-model--dto-catalog)
|
||||
24. [Endpoint URL literals found in code](#24-endpoint-url-literals-found-in-code)
|
||||
25. [Cross-check against existing docs](#25-cross-check-against-existing-docs)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary & key findings
|
||||
|
||||
- **~9 real HTTP-speaking domains** exist today: product/catalog, categories, cart/orders/
|
||||
payments, reviews/questions, telegram session auth, bootstrap, backoffice storefront data,
|
||||
widget manifest, location/regions. Plus **Ed25519 admin auth** — wired to real `HttpClient`
|
||||
but the endpoints are not implemented server-side yet (calls 404 today, by design).
|
||||
- **Two provider seams are token-bound and API-ready today**: `PRODUCT_DATA_PROVIDER`
|
||||
(→ `ApiProductDataProvider`, LIVE) and `CATEGORY_REPOSITORY` (→ `ApiCategoryRepository`, LIVE),
|
||||
plus `CONFIG_PROVIDER` and `BACKOFFICE_DATA_PROVIDER` which switch mock↔api by strategy.
|
||||
- **KEY STRUCTURAL FINDING — most admin CRUD domains have no swap seam.** Of the 11 admin
|
||||
gateway domains, only **categories** (`ADMIN_CATEGORIES_GATEWAY`) and **dashboard-metrics**
|
||||
(`ADMIN_DASHBOARD_METRICS_GATEWAY`) are injected via DI token. The other 9 (orders, products,
|
||||
users, transactions, monitoring, moderation, customers, analytics, and the products/orders
|
||||
gateways reused by analytics/customers) have their facades inject the concrete
|
||||
`Admin*LocalGateway` **class directly**. A backend engineer cannot "just rebind a token" for
|
||||
those — a token must be introduced first. This partially contradicts the blanket
|
||||
"PLANNED / rebind the token" framing in `docs/BACKEND_API.md`.
|
||||
- **Only one real `*Api*Gateway` exists in the admin area**: `AdminCategoriesApiGateway`
|
||||
(`src/app/features/admin/categories/services/admin-categories-api.gateway.ts`). Every other
|
||||
admin domain is local-mock only.
|
||||
- **Media** is bound by class token (`MediaRepository` abstract class → `MockMediaRepository`
|
||||
via `app.config.ts`), so it is MOCK-SWAPPABLE but no real impl exists.
|
||||
- **Content-management and project-editor never hit a dedicated backend** — they read/mutate
|
||||
the in-memory `BootstrapConfig` (loaded once from `GET /bootstrap`) and persist drafts to
|
||||
localStorage. Publishing a marketplace = writing bootstrap back, for which no client write
|
||||
call exists yet (LOCAL-ONLY today; a builder publish endpoint is FUTURE).
|
||||
- **Two API base URLs are in play**: the tenant marketplace API (`ApiConfigService.getBaseUrl()`,
|
||||
default `https://api.dexarmarket.ru:445`, `/api` on localhost) and a separate payment/QR API
|
||||
(`environment.qrApiUrl` = `https://qr.vitanova.network/api`). Auth session API uses
|
||||
`environment.authApiUrl` (= `https://api.dexarmarket.ru:445`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Runtime provider strategy & environment
|
||||
|
||||
`src/app/core/providers/runtime-provider-strategy.service.ts` — `RuntimeProviderStrategyService`
|
||||
decides mock vs api per domain. Modes: `'mock' | 'api' | 'remote-config'`.
|
||||
|
||||
| Method | Returns `mock` when | Else |
|
||||
|---|---|---|
|
||||
| `getBootstrapProviderMode()` | `useMockData` true, OR `useMockBootstrapOnLocal && isLocalhost()` | `api` |
|
||||
| `getBackofficeProviderMode()` | `useMockData` true | `api` |
|
||||
| `getProductProviderMode()` | `useMockData` true | `api` (mock/remote-config fall through to api in token factory) |
|
||||
| `getCategoryProviderMode()` | `useMockData` true, OR `useMockBootstrapOnLocal && isLocalhost()` | `api` |
|
||||
|
||||
Note: `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` token factories currently return the
|
||||
**Api** provider for every mode (the `case 'mock'` falls through) — there is no mock product/
|
||||
category provider class bound. `getCategoryProviderMode()` returning `mock` only matters for
|
||||
`ADMIN_CATEGORIES_GATEWAY` (which does honor it → `AdminCategoriesLocalGateway`).
|
||||
|
||||
`src/environments/environment.ts` relevant keys:
|
||||
|
||||
```
|
||||
useMockData: false
|
||||
useMockBootstrapOnLocal: true
|
||||
allowBootstrapApiOverride: false
|
||||
localhostApiUrl: '/api'
|
||||
tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445'
|
||||
tenantApiBaseUrls: { default: 'https://api.dexarmarket.ru:445', dexarmarket: 'https://api.dexarmarket.ru:445' }
|
||||
apiUrl: '/api'
|
||||
authApiUrl: 'https://api.dexarmarket.ru:445'
|
||||
qrApiUrl: 'https://qr.vitanova.network/api'
|
||||
telegramBot: 'myAMLKYCBOT' (fallback in code: 'DexarSupport_bot')
|
||||
```
|
||||
|
||||
`src/app/core/config/api-config.service.ts` — `ApiConfigService.getBaseUrl()` resolves the
|
||||
tenant marketplace API base: localhost → `localhostApiUrl`; else `tenantApiBaseUrls[tenantKey]`;
|
||||
else `tenantApiTemplate` with `{tenant}` substituted; else optional bootstrap override
|
||||
(gated by `allowBootstrapApiOverride`, reads `bootstrap.apiEndpoints.website.baseUrl` /
|
||||
`bootstrap.tenant.apiBaseUrl`). `isApiRequest(url)` = starts with `/api` or the base URL.
|
||||
`toApiUrl(url)` rewrites a `/api`-prefixed relative URL onto the resolved base.
|
||||
|
||||
Tenant key comes from `TenantResolverService` (`src/app/core/config/tenant-resolver.service.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 3. HTTP interceptor pipeline
|
||||
|
||||
Registered in `src/app/app.config.ts` in this order:
|
||||
|
||||
```
|
||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
|
||||
```
|
||||
|
||||
| Interceptor | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `mockDataInterceptor` | `src/app/interceptors/mock-data.interceptor.ts` | When `environment.useMockData`, short-circuits marketplace endpoints with in-memory fixtures (categories, items, search, cart, qr, callback, purchase-email, sessions). Matches URL patterns — see §24. |
|
||||
| `apiBaseUrlInterceptor` | `src/app/interceptors/api-base-url.interceptor.ts` | Rewrites `/api/*` relative URLs to `ApiConfigService.toApiUrl()`. |
|
||||
| `apiHeadersInterceptor` | `src/app/interceptors/api-headers.interceptor.ts` | For marketplace API requests, sets headers: `X-Region`, `X-Language` (RU/EN/AM), `Currency` (default RUB), `WebSessionID` (auth session id or persisted anonymous 32-hex id in localStorage key `web_session_id`). |
|
||||
| `adminAuthHeadersInterceptor` | `src/app/core/admin-auth/admin-auth-headers.interceptor.ts` | For requests whose URL contains `/admin/`, `/backoffice/`, `/builder/`, `/media/`, sets `AdminWebSessionID` header (from `AdminAuthService.session()`) and `Authorization: Bearer <token>` if an admin token is stored. |
|
||||
| `cacheInterceptor` | `src/app/interceptors/cache.interceptor.ts` | Client-side GET response caching. |
|
||||
|
||||
Header value maps (from `apiHeadersInterceptor`): language `ru→RU, en→EN, hy→AM`;
|
||||
region `moscow→Moscow, spb→ST. Petersburg, yerevan→Yerevan`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Live HTTP endpoints (verified in code)
|
||||
|
||||
All paths relative to `ApiConfigService.getBaseUrl()` unless a full origin is shown. Payment
|
||||
endpoints use `environment.qrApiUrl`; session endpoints use `environment.authApiUrl`.
|
||||
|
||||
### Marketplace API — `src/app/services/api.service.ts` (`ApiService`)
|
||||
|
||||
| Method | HTTP | Path | Notes |
|
||||
|---|---|---|---|
|
||||
| `ping()` | GET | `/ping` | `{ message }` |
|
||||
| `getCategories()` | GET | `/category` | normalized to `Category[]` |
|
||||
| `getCategoryItems(id,count,skip)` | GET | `/category/{categoryID}?count&skip` | `Item[]` |
|
||||
| `getItem(id)` | GET | `/items/{itemID}` | single `Item` |
|
||||
| `searchItems(search,count,skip,opts)` | GET | `/searchitems?search&count&skip[&categoryIDs&minPrice&maxPrice&tag&sort]` | `{ items, total }` |
|
||||
| `getRandomItems(count,categoryID?)` | GET | `/items/randomitems?count[&category]` | `Item[]` (featured) |
|
||||
| `addToCart(sessionId,items)` | POST | `/websession/{sessionId}` | body = item array |
|
||||
| `submitReview(data)` | POST | `/items/{itemID}/callback` | body: rating, comment, sessionID, timestamp |
|
||||
| `submitQuestion(data)` | POST | `/items/{itemID}/questiion` | **NOTE: literal typo `questiion`** matches backend spec |
|
||||
| `createCartPayment(payload)` | POST | `/cart` | `CartPaymentRequest` → `QrCreateResponse` |
|
||||
| `createOrder(payload)` | POST | `/orders` | `CreateOrderRequest` → `CreateOrderResponse`; fire-and-forget after payment |
|
||||
| `submitPurchaseEmail(data)` | POST | `/purchase-email` | email receipt |
|
||||
| `createPayment(payload,headers)` | POST | `{qrApiUrl}/qr` | headers `authorization-key`, `userid-value` |
|
||||
| `checkCartPaymentStatus(qrId)` | GET | `{qrApiUrl}/qr/dynamic/{partnerId}/{qrId}` | partnerId const `web-97ec-9c57-4dde-9037-3a68f7f83750` |
|
||||
| `checkCartCardPaymentStatus(orderId)` | GET | `{qrApiUrl}/card/{partnerId}/{orderId}` | |
|
||||
| `checkPaymentStatus(partnerQrId,qrId)` | GET | `{qrApiUrl}/qr/dynamic/{partnerQrId}/{qrId}` | |
|
||||
|
||||
Also builds an external QR image URL (`https://api.qrserver.com/v1/create-qr-code/...`) — not a backend of this platform.
|
||||
|
||||
### Other live callers
|
||||
|
||||
| Caller (file) | HTTP | Path | Base |
|
||||
|---|---|---|---|
|
||||
| `ApiHealthService` (`src/app/services/api-health.service.ts`) | GET | `/ping` | marketplace base |
|
||||
| `ApiCategoryRepository` (`src/app/core/categories/repositories/api-category.repository.ts`) | GET | `/category` | marketplace base; retry x2 |
|
||||
| `ApiBootstrapProvider` (`src/app/core/bootstrap/providers/api-bootstrap.provider.ts`) | GET | `/bootstrap` | relative |
|
||||
| `MockBootstrapProvider` | GET | `/assets/mock/bootstrap/bootstrap.json` | static asset |
|
||||
| `ApiBackofficeDataProvider` (`src/app/core/backoffice/providers/api-backoffice-data.provider.ts`) | GET | `/api/backoffice/products`, `/api/backoffice/categories` | |
|
||||
| `WidgetManifestService` (`src/app/widgets/registry/widget-manifest.service.ts`) | GET | `bootstrap.widgetRegistry.manifestUrl` or `/assets/mock/bootstrap/widget-manifest.json` | |
|
||||
| `LocationService` (`src/app/services/location.service.ts`) | GET | `/regions` (marketplace base); `http://ip-api.com/json/...` (external geo-IP) | |
|
||||
| `TelegramSessionApiService` (`src/app/services/telegram-session-api.service.ts`) | POST/GET/DELETE | `{authApiUrl}/users/sessions`, `/users/sessions/{id}` | session auth |
|
||||
| `AuthApiService` (`src/app/core/auth/services/auth-api.service.ts`) | GET/POST | `{authApiUrl}/api/admin/auth/challenge|verify|refresh|logout` | **not implemented server-side yet** |
|
||||
| `ApiProductDataProvider` | (delegates to `ApiService`) | see above | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Domain: Auth (customer + admin)
|
||||
|
||||
Two distinct auth mechanisms coexist.
|
||||
|
||||
### 5a. Telegram session auth (LIVE) — customer AND admin
|
||||
|
||||
`src/app/services/telegram-session-api.service.ts` — `TelegramSessionApiService`. Single source
|
||||
for both customer (`AuthService`) and admin (`AdminAuthService`) login; there is no separate
|
||||
admin backend endpoint. Only storage is kept separate (distinct cookie/signals).
|
||||
|
||||
| Method | HTTP | Path | Request | Response (normalized) |
|
||||
|---|---|---|---|---|
|
||||
| `createSession()` | POST | `{authApiUrl}/users/sessions` | `{ webSessionID }` + header `WebSessionID` | `WebSessionStart { webSessionID, url }` (url = `https://t.me/{bot}?start={id}`) |
|
||||
| `checkSessionOnce(id)` | GET | `{authApiUrl}/users/sessions/{id}` | — | `AuthSession | null` (heavily field-tolerant normalizer) |
|
||||
| `logout(id)` | DELETE | `{authApiUrl}/users/sessions/{id}` | header `WebSessionID` | ignored |
|
||||
|
||||
Consumers: `AuthService` (`src/app/services/auth.service.ts`, customer),
|
||||
`AdminAuthService` (`src/app/core/admin-auth/admin-auth.service.ts`, admin — separate cookie
|
||||
`adminSessionID`, has dev-only `devBypassLogin()`), `AuthFacade`
|
||||
(`src/app/core/auth/services/auth-facade.service.ts`) wrapping AuthService/SessionService/
|
||||
PermissionService for components. Also `src/app/shared/qr-login/`.
|
||||
|
||||
Models: `AuthSession`, `WebSessionStart`, `AuthStatus` (`src/app/models/auth.model.ts`);
|
||||
`AdminAuthStatus` (`src/app/models/admin-auth.model.ts`).
|
||||
|
||||
### 5b. Ed25519 challenge/response admin auth (LIVE wiring, backend absent)
|
||||
|
||||
`src/app/core/auth/services/auth-api.service.ts` — `AuthApiService`. Real `HttpClient` wiring
|
||||
against a documented contract that the backend has NOT implemented yet (calls 404 today,
|
||||
mapped to a `backend-unavailable` error screen). No mocks fabricated.
|
||||
|
||||
| Method | HTTP | Path (`{authApiUrl}/api/admin/auth`) | Request | Response |
|
||||
|---|---|---|---|---|
|
||||
| `requestChallenge()` | GET | `/challenge` | — | `AuthChallenge { nonce, issuedAt, expiresAt }` |
|
||||
| `verifySignature(req)` | POST | `/verify` | `VerifySignatureRequest { publicKey, signature, nonce }` | `AuthTokenPair { token, refreshToken }` |
|
||||
| `refresh(req)` | POST | `/refresh` | `RefreshTokenRequest { refreshToken }` | `AuthTokenPair` |
|
||||
| `logout(refreshToken)` | POST | `/logout` | `{ refreshToken }` | void |
|
||||
|
||||
Models: `src/app/core/auth/models/auth-api.model.ts` (`AuthChallenge`, `VerifySignatureRequest`,
|
||||
`AuthTokenPair`, `RefreshTokenRequest`, `JwtClaims`). Supporting:
|
||||
`src/app/core/auth/services/ed25519-keypair.service.ts` (keypair gen/signing);
|
||||
`src/app/core/admin-auth/ed25519-verification.model.ts` +
|
||||
`noop-ed25519-verification.service.ts` (bound in `app.config.ts` via
|
||||
`{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService }`).
|
||||
|
||||
Permissions/roles: `src/app/core/auth/models/permission.model.ts` — `AdminRole`
|
||||
(`Owner|Administrator|Editor|Support|ReadOnly`), `Permission` union.
|
||||
Errors: `src/app/core/auth/models/auth-error.model.ts` — `AuthErrorCode`, `AuthError`.
|
||||
Guards: `src/app/core/admin-auth/admin-auth.guard.ts`, `src/app/guards/**`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Domain: Bootstrap / config / tenant
|
||||
|
||||
The runtime configuration document that drives the entire multi-tenant platform.
|
||||
|
||||
- **Contract interface**: `ConfigProvider` (`src/app/core/config/config-provider.interface.ts`)
|
||||
— `loadBootstrap(): Observable<BootstrapConfig>`.
|
||||
- **DI token**: `CONFIG_PROVIDER` (`src/app/core/config/config-provider.token.ts`), factory
|
||||
switches on `getBootstrapProviderMode()`: `mock` → `MockBootstrapProvider`
|
||||
(`/assets/mock/bootstrap/bootstrap.json`), else → `ApiBootstrapProvider` (`GET /bootstrap`).
|
||||
- **Implementations**: `ApiBootstrapProvider`, `MockBootstrapProvider`
|
||||
(`src/app/core/bootstrap/providers/*`).
|
||||
- **Consuming services**: `ConfigService` (`src/app/core/config/config.service.ts`,
|
||||
holds the bootstrap snapshot), `ApiConfigService`, `FeatureConfigService`,
|
||||
`TenantResolverService`, `FooterResolverService`, `StaticPageResolverService`
|
||||
(all `src/app/core/config/*`).
|
||||
- **Consuming facades**: `UiRuntimeFacade` (`src/app/facades/runtime/ui-runtime.facade.ts`),
|
||||
`WebsiteRuntimeFacade` (`src/app/facades/website/website-runtime.facade.ts`),
|
||||
`ProjectEditorFacade`, `ContentManagementFacade`, `DiagnosticsFacade`.
|
||||
|
||||
`BootstrapConfig` (`src/app/shared/models/config/bootstrap-config.model.ts`) aggregates ~24
|
||||
sub-configs, each its own file under `src/app/shared/models/config/`:
|
||||
|
||||
`schemaVersion, generatedAt, tenant, branding, theme, company, featureFlags, features?,
|
||||
apiEndpoints, localization, seo, permissions, header?, catalog?, layout?, navigation, footer?,
|
||||
productPage?, userExperience?, pages[], staticPages?, widgetRegistry?`
|
||||
|
||||
Sub-config model files (all backend-shaped, served inside bootstrap):
|
||||
`api-endpoints.model.ts` (`ApiEndpointConfig{path,method,timeoutMs?}`, `ApiEndpointsConfig{
|
||||
bootstrap, website:Record<...>, builder:Record<...>, backoffice:Record<...>}`),
|
||||
`tenant.model.ts` (`TenantConfig{id,slug,code,host,name,websiteBaseUrl,builderBaseUrl,
|
||||
backofficeBaseUrl,defaultLocale,supportedLocales,defaultCurrency,supportedCurrencies,timezone}`),
|
||||
`branding.model.ts`, `theme.model.ts`, `company.model.ts`, `feature-flags.model.ts`,
|
||||
`features-config.model.ts`, `footer-config.model.ts`, `header-config.model.ts`, `layout.model.ts`,
|
||||
`localization.model.ts`, `navigation.model.ts`, `page.model.ts`, `permissions.model.ts`,
|
||||
`product-page-config.model.ts`, `catalog-config.model.ts`, `seo.model.ts`,
|
||||
`static-page.model.ts`, `user-experience-config.model.ts`, `widget-registry.model.ts`,
|
||||
`widget.model.ts`, `section.model.ts`. Barrel: `src/app/shared/models/config/index.ts`.
|
||||
|
||||
`ApiEndpointsConfig.website/builder/backoffice` are `Record<string, ApiEndpointConfig>` —
|
||||
i.e. the bootstrap document is where a tenant's PLANNED endpoint paths are declared at runtime.
|
||||
No literal builder/backoffice path constants exist in code (see §24).
|
||||
|
||||
---
|
||||
|
||||
## 7. Domain: Products & catalog
|
||||
|
||||
- **Contract interface**: `ProductDataProvider`
|
||||
(`src/app/core/products/providers/product-data-provider.interface.ts`).
|
||||
- **DI token**: `PRODUCT_DATA_PROVIDER` (`src/app/core/products/product-data-provider.token.ts`)
|
||||
— factory returns `ApiProductDataProvider` for all modes (no mock provider class bound).
|
||||
- **Real impl (LIVE)**: `ApiProductDataProvider`
|
||||
(`src/app/core/products/providers/api-product-data.provider.ts`) — delegates to `ApiService`
|
||||
+ `CategoryService`; contains inline mapping (item→reviews/questions/rating summary).
|
||||
- **Domain service**: `ProductDataService` (`src/app/core/products/product-data.service.ts`)
|
||||
injected by `ProductFacade`.
|
||||
- **Consuming facade**: `ProductFacade` (`src/app/facades/platform/product.facade.ts`).
|
||||
- **Consuming components**: `catalog-container.component.ts`,
|
||||
`product-details-container.component.ts` (`src/app/features/website/**`), home/catalog pages.
|
||||
|
||||
Interface methods: `getProducts(query?)`, `getProduct(productID)`, `getCategories()`,
|
||||
`searchProducts(query)`, `getFeaturedProducts(query?)`, `getLatestProducts(query?)`,
|
||||
`getProductsByCategory(categoryID,query?)`, `getRelatedProducts(query)`, `loadRating(productID)`,
|
||||
`loadReviews(productID,query?)`, `loadQuestions(productID,query?)`, `submitReview(productID,input)`,
|
||||
`submitQuestion(productID,input)`.
|
||||
|
||||
Models — `src/app/core/products/models/`:
|
||||
- `product-domain.model.ts`: `Product = Item` (alias), `ProductCategory = Category`,
|
||||
`ProductSort`, `ProductFilters`, `ProductListQuery`, `ProductSearchQuery`, `ProductListResult`,
|
||||
`RelatedProductsQuery`, `RelatedProductCollection`, `ProductVariantSelection`.
|
||||
- `product-engagement.model.ts`: `RatingStars`, `RatingDistributionEntry`, `RatingSummary`,
|
||||
`Review`, `Answer`, `Question`, `EngagementListQuery`, `EngagementListResult<T>`,
|
||||
`SubmitReviewInput`, `SubmitQuestionInput`.
|
||||
- `catalog-experience.model.ts`: `SearchCriteria`, `FilterDefinition`, `FilterOption`,
|
||||
`SortDefinition`, `CatalogView`, `SearchResult`, layout/nav mode types.
|
||||
|
||||
The **backend-shaped** product DTO is `Item` (`src/app/models/item.model.ts`) — the raw wire
|
||||
shape. `ApiService.normalizeItem()` is the adapter: it reconciles legacy marketplace format and
|
||||
newer backOffice format (string `id`↔numeric `itemID`, `imgs[]`↔`photos[]`, `names[]`↔
|
||||
`translations`, `itemDetails[]`, `description` key/value array↔string, `comments`↔`callbacks`,
|
||||
`specificationGroups`, `variantOptions`, `relatedCollections`, delivery normalization, color
|
||||
`0xRRGGBB`→`#RRGGBB`, remaining→stock band). This is the single largest inline mapper in the
|
||||
codebase — a backend engineer should treat `normalizeItem`/`normalizeCategory` as the tolerance
|
||||
contract. `Item` supporting types: `ProductMedia`, `DescriptionField`, `ItemName`,
|
||||
`ProductSpecificationField/Group`, `ProductVariantOption(Group)`, `RelatedProductCollection`,
|
||||
`DeliveryOption`, `ItemDetail`, `CartItem`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Domain: Categories
|
||||
|
||||
Two parallel category stacks exist (legacy + clean-architecture):
|
||||
|
||||
**Clean stack (MOCK-SWAPPABLE, real impl LIVE):**
|
||||
- **Interface**: `CategoryRepository` (`src/app/core/categories/repositories/category.repository.ts`)
|
||||
— `getCategories(): Observable<CategoryDto[]>`.
|
||||
- **DI token**: `CATEGORY_REPOSITORY` (`src/app/core/categories/category-repository.token.ts`)
|
||||
→ `ApiCategoryRepository` for all modes.
|
||||
- **Real impl (LIVE)**: `ApiCategoryRepository` — `GET /category`, retry x2.
|
||||
- **DTO**: `CategoryDto`, `CategoryNameDto` (`src/app/core/categories/dto/category.dto.ts`).
|
||||
- **Adapter**: `CategoryMapper` (`src/app/core/categories/mappers/category.mapper.ts`) —
|
||||
`CategoryDto → Category` domain (flattens subcategory tree, dedupes by id, language
|
||||
normalization `am→hy`).
|
||||
- **Domain model**: `Category`, `CategoryTranslation`
|
||||
(`src/app/core/categories/models/category-domain.model.ts`).
|
||||
- **Facade**: `CategoryFacade` (`src/app/facades/platform/category.facade.ts`) via
|
||||
`CategoryService` (`src/app/core/categories/category.service.ts`). Utils:
|
||||
`category-tree.utils.ts`.
|
||||
|
||||
**Legacy stack**: `ApiService.getCategories()` → `Category` (`src/app/models/category.model.ts`,
|
||||
with `Subcategory`) via `normalizeCategory()`. Used by `ApiProductDataProvider.getCategories()`.
|
||||
Note: two different `Category` types exist (`src/app/models/category.model.ts` vs
|
||||
`src/app/core/categories/models/category-domain.model.ts`) — a known duplication.
|
||||
|
||||
---
|
||||
|
||||
## 9. Domain: Backoffice storefront data
|
||||
|
||||
Storefront-facing "cards" data (distinct from the admin/backoffice feature area).
|
||||
|
||||
- **Interface**: `BackofficeDataProvider`
|
||||
(`src/app/core/backoffice/providers/backoffice-data-provider.interface.ts`) —
|
||||
`loadProducts(): Observable<ProductCardConfig[]>`, `loadCategories(): Observable<CategoryCardConfig[]>`.
|
||||
- **DI token**: `BACKOFFICE_DATA_PROVIDER` (`src/app/core/backoffice/backoffice-data-provider.token.ts`)
|
||||
— `mock` → `MockBackofficeDataProvider`, else → `ApiBackofficeDataProvider`.
|
||||
- **Impls**: `ApiBackofficeDataProvider` (LIVE, `GET /api/backoffice/products`,
|
||||
`GET /api/backoffice/categories`), `MockBackofficeDataProvider`
|
||||
(`src/app/core/backoffice/providers/*`).
|
||||
- **Models**: `ProductCardConfig` (`src/app/shared/models/ui/product-card.model.ts`),
|
||||
`CategoryCardConfig` (`src/app/shared/models/ui/category-card.model.ts`),
|
||||
`ButtonConfig` (`button.model.ts`). Barrel: `src/app/shared/models/ui/index.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Domain: Cart / orders / payments
|
||||
|
||||
Cart state is **LOCAL-ONLY** but checkout produces LIVE payment/order calls.
|
||||
|
||||
- **`CartService`** (`src/app/services/cart.service.ts`) — signal-based cart, persisted to
|
||||
localStorage key `marketplace_cart` (+ Telegram CloudStorage when in Telegram WebApp). No
|
||||
backend for cart contents. Models: `CartItem` (extends `Item`), `DeliveryOption`.
|
||||
- **Checkout → `ApiService`** (see §4): `POST /cart` (`CartPaymentRequest`),
|
||||
`POST /orders` (`CreateOrderRequest`→`CreateOrderResponse`), `POST /purchase-email`,
|
||||
QR/card status polling on `qrApiUrl`.
|
||||
- Request/response DTOs live inline in `api.service.ts`: `QrCreateRequest`, `QrCreateResponse`,
|
||||
`CartPaymentRequest`, `CreateOrderRequest`, `CreateOrderResponse`, `QrDynamicStatusResponse`.
|
||||
- Admin-side order/transaction views are a **separate** mock domain — see §14.
|
||||
|
||||
---
|
||||
|
||||
## 11. Domain: Reviews & questions (engagement)
|
||||
|
||||
Customer-facing. LIVE via `ApiService`. Interface methods on `ProductDataProvider`:
|
||||
`loadRating`, `loadReviews`, `loadQuestions`, `submitReview`, `submitQuestion`.
|
||||
Endpoints: `POST /items/{id}/callback` (review), `POST /items/{id}/questiion` (question, typo
|
||||
preserved). Reads derive reviews/questions/rating from `GET /items/{id}` payload (no dedicated
|
||||
list endpoints yet). Models in `product-engagement.model.ts` (§7). Admin **moderation** of
|
||||
reviews/reports is a separate mock domain — see §14.
|
||||
|
||||
---
|
||||
|
||||
## 12. Domain: Location / regions
|
||||
|
||||
`LocationService` (`src/app/services/location.service.ts`), LIVE:
|
||||
- `GET /regions` (marketplace base) → `Region[]`; falls back to 6 hardcoded regions on error.
|
||||
- `GET http://ip-api.com/json/?fields=...` (external geo-IP, no key) for auto-detect.
|
||||
Models: `Region`, `GeoIpResponse` (`src/app/models/location.model.ts`). Region id feeds the
|
||||
`X-Region` header (§3).
|
||||
|
||||
---
|
||||
|
||||
## 13. Domain: Widgets / dynamic renderer
|
||||
|
||||
Widget manifest is LIVE (static/remote JSON), widget data is derived from products/categories.
|
||||
|
||||
- **`WidgetManifestService`** (`src/app/widgets/registry/widget-manifest.service.ts`) — GETs
|
||||
`bootstrap.widgetRegistry.manifestUrl` or fallback
|
||||
`/assets/mock/bootstrap/widget-manifest.json` → `WidgetManifestFile`.
|
||||
- **`WidgetRegistryService`** (`src/app/widgets/registry/widget-registry.service.ts`),
|
||||
**`WidgetHostService`** (`src/app/dynamic-renderer/widget-host/widget-host.service.ts`).
|
||||
- Contracts (`src/app/widgets/contracts/`): `widget-manifest.contract.ts`
|
||||
(`WidgetManifestEntry/File`, `WidgetSettingsSchema`, `WidgetMetadataSupport`,
|
||||
`WidgetLayoutSupport`, `WidgetDataSourceName`), `widget-component.contract.ts`
|
||||
(`WidgetRenderContext`, `RegisteredWidget`, `ResolvedWidget`), `widget-data.contract.ts`
|
||||
(`HeroWidgetData`, `CategoriesWidgetData`, `ProductCollectionWidgetData`, `BannerWidgetData`,
|
||||
`HtmlWidgetData`, `PartnersWidgetData`, `FooterWidgetData`, `HeroSlideData`,
|
||||
`WidgetResolvedContext`).
|
||||
- Renderer models: `src/app/dynamic-renderer/{page-renderer,section-renderer,widget-host}/*.model.ts`.
|
||||
- Widget data sources (`featured|latest|category|manual|related|root|parent`) map back onto
|
||||
the product/category providers of §7–§8.
|
||||
|
||||
---
|
||||
|
||||
## 14. Admin gateways (feature area)
|
||||
|
||||
`src/app/features/admin/**`. Each domain follows Facade → Gateway (interface) → LocalGateway.
|
||||
**Only categories and dashboard-metrics use a DI token; all others inject the local class
|
||||
directly (MOCK-ONLY, no seam).** Only `AdminCategoriesApiGateway` is a real HTTP impl.
|
||||
|
||||
| Domain | Interface | Local (mock) impl | Real impl | DI token | Facade | Seam status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Categories | `admin-categories-gateway.interface.ts` (`AdminCategoriesGateway`) | `admin-categories-local.gateway.ts` | `admin-categories-api.gateway.ts` (**HttpClient**) | `ADMIN_CATEGORIES_GATEWAY` (`admin-categories-gateway.token.ts`) | `AdminCategoriesFacade` | MOCK-SWAPPABLE (real impl exists) |
|
||||
| Dashboard metrics | `admin-dashboard-metrics.gateway.interface.ts` (`AdminDashboardMetricsGateway`) | `admin-dashboard-metrics.local.gateway.ts` | none | `ADMIN_DASHBOARD_METRICS_GATEWAY` (`admin-dashboard-metrics-gateway.token.ts`) | `AdminDashboardFacade` | MOCK-SWAPPABLE (token only) |
|
||||
| Orders | `admin-orders-gateway.interface.ts` (`AdminOrdersGateway`) | `admin-orders-local.gateway.ts` | none | **none** | `AdminOrdersFacade` (injects `AdminOrdersLocalGateway`) | MOCK-ONLY (no seam) |
|
||||
| Products | `admin-products-gateway.interface.ts` (`AdminProductsGateway`) | `admin-products-local.gateway.ts` | none | **none** | `AdminProductsFacade` (injects local) | MOCK-ONLY (no seam) |
|
||||
| Users | `admin-users-gateway.interface.ts` (`AdminUsersGateway`) | `admin-users-local.gateway.ts` | none | **none** | `AdminUsersFacade` (injects local) | MOCK-ONLY (no seam) |
|
||||
| Transactions | `admin-transactions-gateway.interface.ts` (`AdminTransactionsGateway`) | `admin-transactions-local.gateway.ts` | none | **none** | `AdminTransactionsFacade` (injects local) | MOCK-ONLY (no seam) |
|
||||
| Monitoring | `admin-monitoring-gateway.interface.ts` (`AdminMonitoringGateway`) | `admin-monitoring-local.gateway.ts` | none | **none** | `AdminMonitoringFacade` (injects local) | MOCK-ONLY (no seam) |
|
||||
| Moderation | `admin-moderation-gateway.interface.ts` (`AdminModerationGateway`) | `admin-moderation-local.gateway.ts` | none | **none** | `AdminModerationFacade` (injects local) | MOCK-ONLY (no seam) |
|
||||
| Customers | (no gateway of its own) | reuses `AdminOrdersLocalGateway` | none | **none** | `AdminCustomersFacade` (injects orders local) | MOCK-ONLY (derived) |
|
||||
| Analytics | (no gateway of its own) | reuses orders/products/moderation local + `ADMIN_CATEGORIES_GATEWAY` + `AdminDashboardFacade` | none | partial (categories token) | `AdminAnalyticsFacade` | MOCK-ONLY (derived) |
|
||||
|
||||
Gateway interface method contracts (the shapes a backend must satisfy):
|
||||
|
||||
- **`AdminCategoriesGateway`**: `loadCategories(filters)`, `loadCategory(id)`, `createCategory`,
|
||||
`updateCategory`, `deleteCategory`, `restoreCategory`, `isSlugTaken(slug,excludingId)`.
|
||||
- **`AdminDashboardMetricsGateway`**: `loadMetrics(): AdminDashboardMetrics`.
|
||||
- **`AdminOrdersGateway`**: `loadOrders(filters)`, `loadOrder(id)`, `updateStatus(id,status)`,
|
||||
`requestRefund(id)`, `addNote(id,note,internal)`, `archiveOrder`, `restoreOrder`, `deleteOrder`.
|
||||
- **`AdminProductsGateway`**: `loadProducts(filters)`, `loadProduct(id)`, `loadCategories()`,
|
||||
`createProduct`, `updateProduct`, `deleteProduct`, `duplicateProduct`, `archiveProduct`,
|
||||
`restoreProduct`.
|
||||
- **`AdminUsersGateway`**: `loadUsers`, `loadRoles`, `loadInvitations`, `loadSessions(userId)`,
|
||||
`loadAudit(userId)`, `setUserRole`, `setUserStatus`, `inviteUser(email,roleId,scope)`,
|
||||
`revokeInvitation`, `revokeSession`.
|
||||
- **`AdminTransactionsGateway`**: `loadTransactions(filters)`, `retryFailed(id)`,
|
||||
`setFraudFlag(id,flagged)`.
|
||||
- **`AdminMonitoringGateway`**: `loadEvents(filters)`, `loadQueues()`, `loadWebhooks()`.
|
||||
- **`AdminModerationGateway`**: `loadReviews(filters)`, `loadReview(id)`, `setReviewStatus`,
|
||||
`setReviewVisible`, `setReviewPinned`, `setReviewFeatured`, `addModeratorNote`, `deleteReview`,
|
||||
`loadReports()`, `setReportStatus(id,status)`.
|
||||
|
||||
Admin model files (all under `src/app/features/admin/<domain>/models/`) — see §23.
|
||||
|
||||
Note: `AdminRole` is defined **twice** with different meaning — `src/app/core/auth/models/
|
||||
permission.model.ts` (auth roles `Owner|Administrator|Editor|Support|ReadOnly`) vs
|
||||
`src/app/features/admin/users/models/admin-user.model.ts` (`AdminRole` interface {id,name,...}).
|
||||
Flag for backend/naming reconciliation.
|
||||
|
||||
Local gateways are localStorage / in-memory backed (facades also inject `LocalStorageService`
|
||||
for overlay persistence, e.g. orders/moderation/categories/products).
|
||||
|
||||
---
|
||||
|
||||
## 15. Domain: Media library
|
||||
|
||||
MOCK-SWAPPABLE via abstract-class token, no real impl.
|
||||
|
||||
- **Contract**: abstract class `MediaRepository` (`src/app/core/media/media-repository.ts`) —
|
||||
`list(params?)`, `upload(file,options?)`, `remove(id)`, `update(id,patch)`, `listFolders()`.
|
||||
- **Binding**: `app.config.ts` → `{ provide: MediaRepository, useClass: MockMediaRepository }`.
|
||||
- **Mock impl**: `MockMediaRepository` (`src/app/core/media/mock-media-repository.service.ts`,
|
||||
uses `HttpClient` to read seed assets). Also `MediaUsageService`
|
||||
(`src/app/core/media/media-usage.service.ts`).
|
||||
- **Facade**: `MediaLibraryFacade` (`src/app/features/backoffice/media/facade/media-library.facade.ts`),
|
||||
page `media-library-page.component.ts`.
|
||||
- **Models** (`src/app/core/media/models/media-asset.model.ts`): `MediaAsset`, `MediaAssetKind`,
|
||||
`MediaSort`, `MediaListParams`, `MediaUploadOptions`, `MediaListResult`.
|
||||
- Admin-auth interceptor already gates `/media/` paths (§3), anticipating a real media backend.
|
||||
|
||||
---
|
||||
|
||||
## 16. Domain: Content management / static pages
|
||||
|
||||
**LOCAL-ONLY** — operates on the already-loaded `BootstrapConfig.staticPages`, no dedicated
|
||||
backend calls. Publishing/writing bootstrap is not implemented client-side (FUTURE).
|
||||
|
||||
- **Facade**: `ContentManagementFacade` (`src/app/features/content-management/facade/content-management.facade.ts`)
|
||||
→ `ContentPageService` (`.../services/content-page.service.ts`). Public API: `pages(bootstrap)`,
|
||||
`hasSeoContent(page)`, `contentHealth(bootstrap)`, `resolvePage(bootstrap,keyOrSlug,locale)`,
|
||||
`validatePages(bootstrap)`, `toBootstrapRecord(bootstrap)`, `serializePages(pages)`,
|
||||
`normalizeSlug`.
|
||||
- `ContentPageService` maps between bootstrap `StaticPagesConfig` and the editor `ContentPage`
|
||||
view model (normalize / validate / `toBootstrapRecord`). This is the adapter.
|
||||
- **Models** (`src/app/features/content-management/models/`): `ContentPage`,
|
||||
`ContentPageTranslation`, `ContentPageSeoConfig`, `ContentPageStatus`,
|
||||
`ContentPageBootstrapInput` (`content-page.model.ts`); `LegalPageKey`, `LegalPageDefinition`
|
||||
(`legal-pages.model.ts`). Backend-shaped counterpart: `StaticPageConfig`,
|
||||
`StaticPagesConfig`, `ResolvedStaticPage`, `LocalizedHtmlContent`, `LocalizedTextContent`
|
||||
(`src/app/shared/models/config/static-page.model.ts`).
|
||||
- Consumers: `static-pages-editor.component.ts`, `page-editor.component.ts`,
|
||||
`static-page.component.ts` (`src/app/pages/static-page/`), resolved via
|
||||
`StaticPageResolverService`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Domain: Project editor / builder
|
||||
|
||||
**LOCAL-ONLY** today — edits an in-memory `BootstrapConfig`, persists drafts to localStorage;
|
||||
no publish/save-to-backend HTTP call exists. A builder API is declared only as
|
||||
`BootstrapConfig.apiEndpoints.builder` (runtime-declared, FUTURE).
|
||||
|
||||
- **Facade**: `ProjectEditorFacade` (`src/app/features/project-editor/facade/project-editor.facade.ts`)
|
||||
— orchestrates undo/redo `History<BootstrapConfig>`, injects `ConfigService`,
|
||||
`ProjectEditorIoService` (JSON import/export of bootstrap), `ProjectEditorPreviewService`,
|
||||
`LocaleSyncService`, `PlatformRuntimeService`, `ProjectValidator`,
|
||||
`ProjectEditorDraftStorageService` (localStorage drafts), `EditorSchemaService`.
|
||||
- Services (`src/app/features/project-editor/services/`): `project-editor-io.service.ts`
|
||||
(`exportBootstrap`/`importBootstrap` = JSON.stringify/parse), `project-editor-draft-storage.service.ts`,
|
||||
`project-editor-preview.service.ts`, `project-validator.service.ts`, `locale-sync.service.ts`.
|
||||
Schema: `schema/editor-schema.service.ts`, `schema/field-schema.model.ts`, `schema/validators/`.
|
||||
- **Models**: `project-editor.model.ts` (`ProjectEditorState`, `ProjectEditorSectionId`,
|
||||
`ProjectEditorWidgetPreset`, `BuilderSectionStatus`), `builder/builder-groups.model.ts`.
|
||||
- Consumers: `project-editor-page.component.ts`, `homepage-section.component.ts`,
|
||||
`project-editor-nav.component.ts`. Also drives admin products/categories/dashboard facades
|
||||
(which inject `ProjectEditorFacade`).
|
||||
|
||||
---
|
||||
|
||||
## 18. Domain: Search
|
||||
|
||||
**LOCAL-ONLY orchestration over the product/category providers** — no dedicated search backend;
|
||||
`SearchFacade` composes `ProductFacade` + `CategoryFacade` results and manages history/trending/
|
||||
autocomplete/cache client-side.
|
||||
|
||||
- **Facade**: `SearchFacade` (`src/app/features/search/facade/search.facade.ts`) injects
|
||||
`ProductFacade`, `CategoryFacade`, `SearchAutocompleteService`, `SearchHistoryService`,
|
||||
`SearchTrendingService`, `SearchCacheService`, `SearchStore`, `TranslateService`.
|
||||
- Services (`src/app/features/search/services/`): `search-autocomplete.service.ts`,
|
||||
`search-history.service.ts` + `search-history.repository.ts` (interface
|
||||
`SearchHistoryRepository{load,save,clear}`, localStorage), `search-trending.service.ts`,
|
||||
`search-cache.service.ts`. Store: `store/search.store.ts`.
|
||||
- **Models**: `src/app/features/search/models/search.model.ts` (`SearchQuery`, `SearchResult<T>`,
|
||||
`SearchSuggestion`, `SearchFilterType`, `FilterGroup`, `FilterOption`, `SortOption`,
|
||||
`SearchHistory`, `SearchAnalyticsEvent`, `SearchNavigationTarget`), `search-state.model.ts`
|
||||
(`SearchState`). Duplicated under `src/app/core/search/models/`.
|
||||
- Underlying live traffic is `GET /searchitems` (§4) via `ProductFacade.searchProducts`.
|
||||
|
||||
---
|
||||
|
||||
## 19. Domain: User experience (wishlist/compare/etc.)
|
||||
|
||||
**LOCAL-ONLY** (guest-first). MOCK-SWAPPABLE token exists for a future authenticated backend.
|
||||
|
||||
- **Interface**: `UserExperienceRepository`
|
||||
(`src/app/core/user-experience/repositories/user-experience.repository.ts`).
|
||||
- **DI token**: `USER_EXPERIENCE_REPOSITORY`
|
||||
(`src/app/core/user-experience/user-experience-repository.token.ts`) → currently always
|
||||
`LocalUserExperienceRepository` (localStorage). Comment notes it "can be switched to
|
||||
authenticated repository later."
|
||||
- **Facade**: `UserExperienceFacade` (`src/app/facades/platform/user-experience.facade.ts`) —
|
||||
wishlist / compare / recently-viewed / saved-searches / continue-browsing, all signals.
|
||||
- **Models** (`src/app/core/user-experience/models/user-experience.model.ts`): `FavoriteItem`,
|
||||
`ComparedProduct`, `RecentlyViewedItem`, `SavedSearch`, `ContinueBrowsingState`. Config shape:
|
||||
`user-experience-config.model.ts` (limits, from bootstrap).
|
||||
|
||||
---
|
||||
|
||||
## 20. Domain: Diagnostics
|
||||
|
||||
**LOCAL-ONLY** — inspects runtime/bootstrap/widget state; the one live-ish probe is API ping.
|
||||
|
||||
- **Facade**: `DiagnosticsFacade` (`src/app/features/diagnostics/facade/diagnostics.facade.ts`)
|
||||
injects `ConfigService`, `TenantResolverService`, `PlatformRuntimeStateService`,
|
||||
`RuntimeDiagnosticsService`, `WidgetManifestService`, `WidgetRegistryService`,
|
||||
`RuntimeProviderStrategyService`, `DiagnosticsLoggerService`, `TranslateService`, `Router`.
|
||||
- Validators: `validators/runtime-diagnostics.validator.ts` (uses `HttpClient` for API health
|
||||
probe), `bootstrap-diagnostics.validator.ts`, `diagnostics-health-score.util.ts`.
|
||||
- **Models** (`src/app/features/diagnostics/models/diagnostics.model.ts`): `DiagnosticEntry`,
|
||||
`DiagnosticSeverity`, `DiagnosticsHealthSummary`, `DiagnosticsReport`.
|
||||
|
||||
---
|
||||
|
||||
## 21. Facade catalog
|
||||
|
||||
| Facade | File | Depends on | Consumed by (examples) |
|
||||
|---|---|---|---|
|
||||
| `ProductFacade` | `facades/platform/product.facade.ts` | `ProductDataService` → `PRODUCT_DATA_PROVIDER` | catalog/product containers, `SearchFacade` |
|
||||
| `CategoryFacade` | `facades/platform/category.facade.ts` | `CategoryService` → `CATEGORY_REPOSITORY` | catalog nav, `SearchFacade` |
|
||||
| `SearchFacade` | `features/search/facade/search.facade.ts` | ProductFacade, CategoryFacade, search services | search bar/pages |
|
||||
| `UserExperienceFacade` | `facades/platform/user-experience.facade.ts` | `USER_EXPERIENCE_REPOSITORY` | wishlist/compare UI |
|
||||
| `UiRuntimeFacade` | `facades/runtime/ui-runtime.facade.ts` | `ConfigService` | header/branding |
|
||||
| `WebsiteRuntimeFacade` | `facades/website/website-runtime.facade.ts` | config/page renderer | dynamic pages |
|
||||
| `AuthFacade` | `core/auth/services/auth-facade.service.ts` | AuthService, SessionService, PermissionService | login/guarded UI |
|
||||
| `MediaLibraryFacade` | `features/backoffice/media/facade/media-library.facade.ts` | `MediaRepository` | media page |
|
||||
| `ContentManagementFacade` | `features/content-management/facade/...` | `ContentPageService` (bootstrap) | content dashboard/editor |
|
||||
| `ProjectEditorFacade` | `features/project-editor/facade/...` | config + editor services (localStorage) | builder pages, admin facades |
|
||||
| `DiagnosticsFacade` | `features/diagnostics/facade/...` | runtime/config/widget services | diagnostics page |
|
||||
| `AdminCategoriesFacade` | `features/admin/categories/facade/...` | `ADMIN_CATEGORIES_GATEWAY`, ProjectEditorFacade | admin categories pages |
|
||||
| `AdminProductsFacade` | `features/admin/products/facade/...` | `AdminProductsLocalGateway`, ProjectEditorFacade | admin products pages |
|
||||
| `AdminOrdersFacade` | `features/admin/orders/facade/...` | `AdminOrdersLocalGateway` | admin orders pages |
|
||||
| `AdminUsersFacade` | `features/admin/users/facade/...` | `AdminUsersLocalGateway` | admin users pages |
|
||||
| `AdminTransactionsFacade` | `features/admin/transactions/facade/...` | `AdminTransactionsLocalGateway` | admin transactions pages |
|
||||
| `AdminMonitoringFacade` | `features/admin/monitoring/facade/...` | `AdminMonitoringLocalGateway` | admin monitoring page |
|
||||
| `AdminModerationFacade` | `features/admin/moderation/facade/...` | `AdminModerationLocalGateway` | moderation pages |
|
||||
| `AdminCustomersFacade` | `features/admin/customers/facade/...` | `AdminOrdersLocalGateway` (derives customers from orders) | customers pages |
|
||||
| `AdminAnalyticsFacade` | `features/admin/analytics/facade/...` | orders/products/moderation local + `ADMIN_CATEGORIES_GATEWAY` + `AdminDashboardFacade` | analytics page |
|
||||
| `AdminDashboardFacade` | `features/admin/dashboard/facade/...` | `ADMIN_DASHBOARD_METRICS_GATEWAY`, ProjectEditorFacade, AdminAuthService | admin dashboard |
|
||||
|
||||
`ProductFacade` public API: `getProducts, getProduct, getCategories, searchProducts,
|
||||
getFeaturedProducts, getLatestProducts, getProductsByCategory, getRelatedProducts, loadRating,
|
||||
loadReviews, loadQuestions, submitReview, submitQuestion, search(criteria), filter, sort,
|
||||
loadCatalog`. `CategoryFacade`: signals (`allCategories, categoryTree, rootCategories,
|
||||
selectedCategory, breadcrumb, children, loading, error`) + `loadCategories, selectCategory,
|
||||
getAllCategories, getCategoryTree, getRootCategories, getCategoryById, getBreadcrumb, getChildren`.
|
||||
`UserExperienceFacade`: `isInWishlist, toggleWishlist, clearWishlist, isInCompare, addToCompare,
|
||||
removeFromCompare, clearCompare, trackRecentlyViewed, saveSearch, removeSavedSearch,
|
||||
saveContinueBrowsing, getContinueBrowsing` + wishlist/compare signals & counts.
|
||||
|
||||
---
|
||||
|
||||
## 22. Gateway / provider master table
|
||||
|
||||
| Gateway/provider | Interface path | Mock/local impl | Real/API impl | DI token | Consuming facade(s) | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ConfigProvider | `core/config/config-provider.interface.ts` | `core/bootstrap/providers/mock-bootstrap.provider.ts` | `core/bootstrap/providers/api-bootstrap.provider.ts` | `CONFIG_PROVIDER` | UiRuntime, WebsiteRuntime, ProjectEditor, ContentMgmt, Diagnostics (via ConfigService) | LIVE (`GET /bootstrap`) |
|
||||
| ProductDataProvider | `core/products/providers/product-data-provider.interface.ts` | none bound | `core/products/providers/api-product-data.provider.ts` | `PRODUCT_DATA_PROVIDER` | ProductFacade | LIVE |
|
||||
| CategoryRepository | `core/categories/repositories/category.repository.ts` | none bound | `core/categories/repositories/api-category.repository.ts` | `CATEGORY_REPOSITORY` | CategoryFacade | LIVE |
|
||||
| BackofficeDataProvider | `core/backoffice/providers/backoffice-data-provider.interface.ts` | `mock-backoffice-data.provider.ts` | `api-backoffice-data.provider.ts` | `BACKOFFICE_DATA_PROVIDER` | storefront cards | LIVE (`/api/backoffice/*`) |
|
||||
| UserExperienceRepository | `core/user-experience/repositories/user-experience.repository.ts` | `local-user-experience.repository.ts` | none | `USER_EXPERIENCE_REPOSITORY` | UserExperienceFacade | LOCAL-ONLY |
|
||||
| MediaRepository | `core/media/media-repository.ts` (abstract class) | `core/media/mock-media-repository.service.ts` | none | `MediaRepository` class (app.config.ts) | MediaLibraryFacade | MOCK-SWAPPABLE |
|
||||
| SearchHistoryRepository | `features/search/services/search-history.repository.ts` | (localStorage impl) | none | (injected concretely) | SearchFacade (via SearchHistoryService) | LOCAL-ONLY |
|
||||
| AdminCategoriesGateway | `features/admin/categories/services/admin-categories-gateway.interface.ts` | `admin-categories-local.gateway.ts` | `admin-categories-api.gateway.ts` | `ADMIN_CATEGORIES_GATEWAY` | AdminCategoriesFacade, AdminAnalyticsFacade | MOCK-SWAPPABLE (real impl exists) |
|
||||
| AdminDashboardMetricsGateway | `features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts` | `admin-dashboard-metrics.local.gateway.ts` | none | `ADMIN_DASHBOARD_METRICS_GATEWAY` | AdminDashboardFacade | MOCK-SWAPPABLE (token only) |
|
||||
| AdminOrdersGateway | `features/admin/orders/services/admin-orders-gateway.interface.ts` | `admin-orders-local.gateway.ts` | none | **none** | AdminOrdersFacade, AdminCustomersFacade, AdminAnalyticsFacade | MOCK-ONLY (no seam) |
|
||||
| AdminProductsGateway | `features/admin/products/services/admin-products-gateway.interface.ts` | `admin-products-local.gateway.ts` | none | **none** | AdminProductsFacade, AdminAnalyticsFacade | MOCK-ONLY (no seam) |
|
||||
| AdminUsersGateway | `features/admin/users/services/admin-users-gateway.interface.ts` | `admin-users-local.gateway.ts` | none | **none** | AdminUsersFacade | MOCK-ONLY (no seam) |
|
||||
| AdminTransactionsGateway | `features/admin/transactions/services/admin-transactions-gateway.interface.ts` | `admin-transactions-local.gateway.ts` | none | **none** | AdminTransactionsFacade | MOCK-ONLY (no seam) |
|
||||
| AdminMonitoringGateway | `features/admin/monitoring/services/admin-monitoring-gateway.interface.ts` | `admin-monitoring-local.gateway.ts` | none | **none** | AdminMonitoringFacade | MOCK-ONLY (no seam) |
|
||||
| AdminModerationGateway | `features/admin/moderation/services/admin-moderation-gateway.interface.ts` | `admin-moderation-local.gateway.ts` | none | **none** | AdminModerationFacade, AdminAnalyticsFacade | MOCK-ONLY (no seam) |
|
||||
| (Auth session) | — (`TelegramSessionApiService`) | mock via `mockDataInterceptor` | `services/telegram-session-api.service.ts` | n/a (concrete) | AuthService, AdminAuthService, AuthFacade | LIVE |
|
||||
| (Ed25519 admin auth) | — (`AuthApiService`) | none | `core/auth/services/auth-api.service.ts` | n/a (concrete) | AuthService (Ed25519 flow) | LIVE wiring, backend absent |
|
||||
|
||||
---
|
||||
|
||||
## 23. Model / DTO catalog
|
||||
|
||||
Grouped by boundary role. B = backend-shaped/wire DTO, V = frontend view model, C = bootstrap
|
||||
config shape. Adapter column names the mapper if distinct.
|
||||
|
||||
### Core wire DTOs / domain (B)
|
||||
- `Item` + supporting (`src/app/models/item.model.ts`) — **primary product wire shape**; adapter
|
||||
`ApiService.normalizeItem()`.
|
||||
- `Category`, `Subcategory` (`src/app/models/category.model.ts`) — legacy category wire; adapter
|
||||
`ApiService.normalizeCategory()`.
|
||||
- `CategoryDto`, `CategoryNameDto` (`src/app/core/categories/dto/category.dto.ts`) — clean-stack
|
||||
wire DTO; adapter `CategoryMapper`.
|
||||
- `Region`, `GeoIpResponse` (`src/app/models/location.model.ts`).
|
||||
- Payment/order DTOs inline in `src/app/services/api.service.ts`: `QrCreateRequest`,
|
||||
`QrCreateResponse`, `CartPaymentRequest`, `CreateOrderRequest`, `CreateOrderResponse`,
|
||||
`QrDynamicStatusResponse`.
|
||||
- Auth: `AuthSession`, `WebSessionStart` (`src/app/models/auth.model.ts`); `AuthChallenge`,
|
||||
`VerifySignatureRequest`, `AuthTokenPair`, `RefreshTokenRequest`, `JwtClaims`
|
||||
(`src/app/core/auth/models/auth-api.model.ts`).
|
||||
|
||||
### Domain / view models (V)
|
||||
- Products: `Product`(=Item alias), `ProductListQuery`, `ProductSearchQuery`, `ProductListResult`,
|
||||
`ProductFilters`, `RelatedProductsQuery`, `RelatedProductCollection`, `ProductVariantSelection`
|
||||
(`core/products/models/product-domain.model.ts`).
|
||||
- Engagement: `Review`, `Answer`, `Question`, `RatingSummary`, `RatingDistributionEntry`,
|
||||
`EngagementListQuery`, `EngagementListResult<T>`, `SubmitReviewInput`, `SubmitQuestionInput`
|
||||
(`core/products/models/product-engagement.model.ts`).
|
||||
- Catalog experience: `SearchCriteria`, `FilterDefinition`, `FilterOption`, `SortDefinition`,
|
||||
`CatalogView`, `SearchResult` (`core/products/models/catalog-experience.model.ts`);
|
||||
catalog state (`features/website/catalog/models/catalog-state.model.ts`).
|
||||
- Category domain: `Category`, `CategoryTranslation` (`core/categories/models/category-domain.model.ts`).
|
||||
- Media: `MediaAsset` + params/results (`core/media/models/media-asset.model.ts`).
|
||||
- User experience: `FavoriteItem`, `ComparedProduct`, `RecentlyViewedItem`, `SavedSearch`,
|
||||
`ContinueBrowsingState` (`core/user-experience/models/user-experience.model.ts`).
|
||||
- Search: `search.model.ts` + `search-state.model.ts` (`features/search/models/`, dup in `core/search/models/`).
|
||||
- Content: `ContentPage`, `ContentPageTranslation`, `ContentPageSeoConfig`, `ContentPageStatus`,
|
||||
`ContentPageBootstrapInput`, `LegalPageKey`, `LegalPageDefinition`
|
||||
(`features/content-management/models/`); adapter `ContentPageService`.
|
||||
- Project editor: `ProjectEditorState`, `ProjectEditorSectionId`, `ProjectEditorWidgetPreset`,
|
||||
`BuilderSectionStatus` (`features/project-editor/models/`), `builder-groups.model.ts`.
|
||||
- Diagnostics: `DiagnosticEntry`, `DiagnosticsHealthSummary`, `DiagnosticsReport`
|
||||
(`features/diagnostics/models/diagnostics.model.ts`).
|
||||
- Widgets: contracts in `src/app/widgets/contracts/*` and renderer `*.model.ts` (see §13).
|
||||
|
||||
### Admin models (V, all under `features/admin/<domain>/models/`)
|
||||
- `admin-order.model.ts`: `AdminOrder`, `AdminOrderCustomer`, `AdminOrderPayment`,
|
||||
`AdminOrderShipping`, `AdminOrderItem`, `AdminOrderTimelineEntry`, `AdminOrderStatus`,
|
||||
`AdminOrderPaymentStatus`, `AdminOrderTimelineEventKey`, `AdminOrderListFilters`,
|
||||
`AdminOrdersListResult`.
|
||||
- `admin-product.model.ts`: `AdminProduct` (+ `AdminProductMedia`, `AdminProductSpecification`,
|
||||
`AdminProductVariant(Price)`, `AdminProductVariantAttributeDef`, `AdminProductAttribute`,
|
||||
`AdminProductTranslation`, `AdminProductSeo`, `AdminProductReview`, `AdminProductQuestion`),
|
||||
`AdminProductListFilters`, `AdminProductsListResult`, `AdminProductCategoryOption`, status/sort/mode types.
|
||||
- `admin-category.model.ts`: `AdminCategory`, `AdminCategoryTranslation`, `AdminCategorySeo`,
|
||||
`AdminCategoryAttribute`, `AdminCategoryListFilters`, status/mode types.
|
||||
- `admin-user.model.ts`: `AdminUser`, `AdminRole`, `AdminInvitation`, `AdminSession`,
|
||||
`AdminUserAuditEntry`, scope/status/invitation-status types.
|
||||
- `admin-transaction.model.ts`: `AdminTransaction`, `AdminTransactionAuditEntry`,
|
||||
`AdminTransactionListFilters`, `AdminTransactionsListResult`, type/status types.
|
||||
- `admin-monitoring.model.ts`: `AdminMonitoringEvent`, `AdminMonitoringEventFilters`,
|
||||
`AdminQueue`, `AdminWebhookDelivery`, category/level/queue/webhook status types.
|
||||
- `admin-review.model.ts`: `AdminReview`, `AdminReviewTimelineEntry`, `AdminReviewListFilters`,
|
||||
`AdminReviewsListResult`, status/timeline types.
|
||||
- `admin-report.model.ts`: `AdminReport`, `AdminReportTargetType`, `AdminReportStatus`.
|
||||
- `admin-customer.model.ts`: `AdminCustomer`.
|
||||
- `admin-analytics.model.ts`: `AdminAnalyticsSummary`, `AdminAnalyticsSeriesPoint`,
|
||||
`AdminAnalyticsTopProduct`, `AdminLowStockProduct`, `AdminRecentActivityEntry`,
|
||||
`AdminMarketplaceHealthCheck`, `AdminProductAnalytics(Row)`, `AdminCustomerAnalytics`,
|
||||
`AdminRecommendationCard`, date-range/severity/health types.
|
||||
- `admin-dashboard.model.ts`: `AdminDashboardMetrics`, `AdminDashboardCardState<T>`,
|
||||
`AdminDashboardQuickAction(Id)`, `AdminDashboardActivityEntry`, `AdminDashboardHealthCheck`,
|
||||
`AdminDashboardHomeHealthCheck`, `AdminDashboardDraftField`, `AdminDashboardShortcut`, status types.
|
||||
- Shell: `features/admin/shell/admin-nav.model.ts`.
|
||||
|
||||
### Bootstrap config shapes (C)
|
||||
All under `src/app/shared/models/config/` — see §6 for the full list (24 files + barrel).
|
||||
|
||||
---
|
||||
|
||||
## 24. Endpoint URL literals found in code
|
||||
|
||||
Marketplace API (relative to base): `/ping`, `/bootstrap`, `/category`, `/category/{id}`,
|
||||
`/items/{id}`, `/items/randomitems`, `/searchitems`, `/cart`, `/orders`, `/purchase-email`,
|
||||
`/regions`, `/websession/{sessionId}`, `/items/{id}/callback`, `/items/{id}/questiion`.
|
||||
|
||||
Backoffice storefront: `/api/backoffice/products`, `/api/backoffice/categories`.
|
||||
|
||||
Payment (`qrApiUrl` = `https://qr.vitanova.network/api`): `/qr`, `/qr/dynamic/{partnerId}/{qrId}`,
|
||||
`/card/{partnerId}/{orderId}`. Const partner id `web-97ec-9c57-4dde-9037-3a68f7f83750`.
|
||||
|
||||
Session auth (`authApiUrl`): `/users/sessions`, `/users/sessions/{id}`.
|
||||
|
||||
Ed25519 admin auth (`authApiUrl`): `/api/admin/auth/challenge|verify|refresh|logout`
|
||||
(not implemented server-side).
|
||||
|
||||
Static assets (not backend): `/assets/mock/bootstrap/bootstrap.json`,
|
||||
`/assets/mock/bootstrap/widget-manifest.json`.
|
||||
|
||||
External (not this platform): `http://ip-api.com/json/...` (geo-IP),
|
||||
`https://api.qrserver.com/v1/create-qr-code/...` (QR image), `https://t.me/{bot}`,
|
||||
`tg://resolve?...`.
|
||||
|
||||
`mockDataInterceptor` URL matchers (mock mode only): `/ping`, `/users/sessions[/{id}]`,
|
||||
`/category`, `/category/{id}`, `/items/{id}`, `/searchitems`, `/randomitems`, `/cart`,
|
||||
`/websession/{id}[/qr]`, `/qr`, `/items/{id}/callback`, `/purchase-email`, `/qr/payment/{id}`.
|
||||
|
||||
**No literal `/admin/*`, `/builder/*`, or per-admin-domain backoffice CRUD paths exist in code.**
|
||||
Those live only as `apiEndpoints.{builder,backoffice}` records inside the runtime bootstrap
|
||||
document, and admin gateways are in-memory (they never construct a URL). Any concrete admin CRUD
|
||||
path is therefore a proposal, not a verified literal — consistent with `docs/BACKEND_API.md`
|
||||
Assumption #2.
|
||||
|
||||
The admin-auth-headers interceptor gates these path **segments** (anticipatory, not called yet):
|
||||
`/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
||||
|
||||
---
|
||||
|
||||
## 25. Cross-check against existing docs
|
||||
|
||||
Skimmed: `docs/BACKEND_API.md` (canonical master spec, CURRENT/PLANNED/FUTURE tagging),
|
||||
`docs/AUTH.md`, `docs/ADMIN.md`, `docs/BACKEND_API_REMAINING_WORK.md`,
|
||||
`docs/architecture/foundation/**`, `docs/backend/BACKEND-INTEGRATION.md`.
|
||||
|
||||
Agreements (preserve these conventions downstream):
|
||||
- `docs/BACKEND_API.md` already uses `GET /bootstrap`, the `*LocalGateway` → `*ApiGateway`
|
||||
rebind pattern, and frozen auth/payment (ADR-010). Its CURRENT/PLANNED/FUTURE tagging maps
|
||||
cleanly onto LIVE / MOCK-SWAPPABLE / MOCK-ONLY here.
|
||||
- Assumption #2 (builder/backoffice paths are proposals, not literals) is confirmed by code.
|
||||
- `submitQuestion` typo `questiion` and `callback` review path confirmed against code.
|
||||
|
||||
Discrepancies / things to flag for a human:
|
||||
1. **`docs/BACKEND_API.md` PLANNED framing implies every admin domain is a token rebind.**
|
||||
In code, only `ADMIN_CATEGORIES_GATEWAY` and `ADMIN_DASHBOARD_METRICS_GATEWAY` are
|
||||
token-bound. Orders, products, users, transactions, monitoring, moderation (and derived
|
||||
customers/analytics) inject the concrete `*LocalGateway` directly — no seam. A backend
|
||||
integration for those requires adding a token first. This should be reconciled in the docs.
|
||||
2. **Only one real admin API impl exists** (`AdminCategoriesApiGateway`). Everything else admin
|
||||
is mock. Docs that describe admin endpoints as "PLANNED, served by local gateway" are
|
||||
accurate in spirit but the swap ergonomics differ per domain (see #1).
|
||||
3. **Duplicate `Category` types** (`src/app/models/category.model.ts` vs
|
||||
`core/categories/models/category-domain.model.ts`) and **duplicate `AdminRole`**
|
||||
(auth `permission.model.ts` string-union vs users `admin-user.model.ts` interface) — naming
|
||||
collisions a backend/contract author should be warned about.
|
||||
4. **Duplicate search models** under `features/search/models/` and `core/search/models/`.
|
||||
5. **Content-management & project-editor "save/publish" has no client HTTP call.** Docs that
|
||||
imply a builder publish endpoint should tag it FUTURE — there is no `PUT /bootstrap` or
|
||||
builder-write call anywhere in code today; changes live in localStorage drafts + in-memory
|
||||
bootstrap only.
|
||||
6. `PRODUCT_DATA_PROVIDER` / `CATEGORY_REPOSITORY` token factories return the Api provider even
|
||||
in `mock` mode (no mock class bound) — so `useMockData` does NOT mock products/categories at
|
||||
the provider layer; mocking there relies entirely on `mockDataInterceptor`. Worth noting if a
|
||||
doc claims a mock product provider exists.
|
||||
|
||||
---
|
||||
|
||||
_Generated from source on branch `B2B`. Every path above is repo-relative to
|
||||
`F:\dx\remote\marketplaces\`._
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: ADR-0001
|
||||
title: Extract auth and payment into shared @marketplaces packages
|
||||
status: active
|
||||
date: 2026-08-17
|
||||
supersedes: []
|
||||
tags: [architecture, auth, payment, monorepo]
|
||||
---
|
||||
|
||||
# ADR-0001: Extract auth and payment into shared @marketplaces packages
|
||||
|
||||
## Context
|
||||
|
||||
`marketplaces` currently owns auth end-to-end: customer auth (`core/auth` — VK ID, OTP, session, facade), admin auth (`core/admin-auth` — ed25519-verified admin sessions, permission guards, interceptor), and a legacy `services/auth.service.ts`. Payment/finance logic (`core/finance`, `core/pricing`) is server-owned per [Phase 1](../../backend/BACKEND-INTEGRATION.md) and [Phase 7](../../backend/BACKEND-INTEGRATION.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
|
||||
|
||||
Multiple marketplace projects beyond this repo need the same auth and payment client logic. Duplicating it per-project drifts fast (auth bugs get fixed in one place, not others) and blocks a consistent security posture across projects — directly relevant to [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
|
||||
|
||||
## Decision
|
||||
|
||||
Extract auth and payment client logic into two standalone, independently versioned npm packages:
|
||||
|
||||
- `@marketplaces/auth` — customer auth (VK ID/OTP/session), admin auth (ed25519 verification, permission guards, interceptors), token/session management.
|
||||
- `@marketplaces/payment` — payment/finance client gateways, FX/pricing models, checkout client contracts (thin — business logic stays backend per Phase 1/7).
|
||||
|
||||
Each package:
|
||||
1. Lives in its own git repo (handed over separately; this repo does not host it long-term).
|
||||
2. Is consumed by `marketplaces` (and other projects) as an installed node_modules dependency — imported, never copy-pasted.
|
||||
3. Is versioned with semver; CI on the package repo auto-bumps and publishes on push to `main`, driven by conventional commit prefixes already used in this repo (`feat:`/`fix:`/etc — semantic-release reads these directly).
|
||||
4. Ships with its own test suite; `marketplaces` treats it as a black-box dependency, not source to edit in place.
|
||||
|
||||
Rollout order: scaffold packages and CI in this repo first (reversible, local-only) → hand over target git repo → publish → migrate `marketplaces` call sites to import from the package → delete the in-repo originals only after the app builds and passes tests against the package.
|
||||
|
||||
## Amendment 2026-08-18 — distribution mechanism
|
||||
|
||||
The original decision left distribution open ("private registry ... or installed straight from git"). A private Verdaccio registry was stood up on the dev server and both packages published to it. **That approach was then abandoned**: the registry listens on `127.0.0.1:4873` behind a firewall allowing only 80/443/SSH, so neither CI runners nor developers could install without an SSH tunnel. That broke `marketplaces`' existing `architecture-governance` workflow, whose `npm ci` step could no longer resolve `@marketplaces/auth`.
|
||||
|
||||
Distribution is now **git release branches**: `release/auth` and `release/payment` in vitanovaPackages, each an orphan branch whose root *is* the package (`package.json` + built `dist/`), force-pushed by CI on every release. Consumers install with `git+<repo>#release/auth` — no registry, no token, no tunnel, no CI secret; anonymous git read suffices.
|
||||
|
||||
The Verdaccio instance still runs but nothing depends on it. Making a registry the primary path again would require a reverse proxy plus TLS on the dev server, which buys nothing over the current approach at this scale.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `marketplaces` loses direct edit access to auth/payment source — changes go through the package's own repo/PR/release cycle. Slower iteration, but consistent behavior across all consuming projects.
|
||||
- ~30 call sites in `marketplaces` (see `core/auth`, `core/admin-auth`, `services/auth.service.ts`, interceptors) need import rewiring during migration — tracked as follow-up work, not done in this ADR.
|
||||
- New failure mode: `marketplaces` builds now depend on `sources.vitanova.network` being reachable. A branch ref also tracks its tip, so an install can pick up a new build — acceptable while the package churns, but pin to a commit SHA once it stabilises.
|
||||
- [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md) §8 (admin provisioning) becomes package-owned behavior once migrated — that doc's endpoint contracts stay backend-side and unaffected, only the frontend client implementation moves.
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
id: ADR-0001
|
||||
title: Multi-tenant marketplace platform vision and config-driven architecture
|
||||
status: active
|
||||
date: 2026-07-13
|
||||
tags: ["architecture", "philosophy", "multi-tenant", "bootstrap"]
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
This is not a single marketplace — it is a multi-tenant platform powering unlimited
|
||||
marketplaces (e.g. electronics.example.com, books.example.com) from one codebase.
|
||||
Every marketplace is configured from the backend via a bootstrap configuration
|
||||
(`GET /bootstrap`). No marketplace-specific code may exist in the frontend.
|
||||
|
||||
## Decision
|
||||
|
||||
- The frontend (Angular 20, standalone components, Signals, RxJS, SCSS) is a pure
|
||||
renderer. It owns render, navigation, interaction, validation, animations only.
|
||||
- The backend (ASP.NET Core REST API) owns branding, pages, layouts, languages,
|
||||
homepage, navigation, categories, products, footer, static pages, payment
|
||||
configuration, and enabled features.
|
||||
- Flow: Bootstrap → Runtime Provider → Configuration Store → Renderer → Widgets.
|
||||
Nothing depends on build-time environments; everything depends on runtime
|
||||
configuration.
|
||||
- Bootstrap contains only data needed before the app starts (name, logo, colors,
|
||||
languages, footer pages, homepage layout, navigation, enabled widgets). It must
|
||||
never contain products, orders, cart, or users.
|
||||
- Widgets never own page spacing — only their own internal layout. The renderer
|
||||
owns sections, spacing, and page width.
|
||||
- Homepage is composed from a configurable, ordered list of sections (Section
|
||||
Engine): Hero, Categories, Featured Products, Banner, Latest Products, Custom
|
||||
HTML, Newsletter, etc.
|
||||
- All layouts (homepage, PLP, etc.) must be backend-configurable without frontend
|
||||
changes.
|
||||
- All user-facing text is translatable via a `translations.{lang}` shape, not a
|
||||
flat `title` field. Adding/removing a supported language must automatically
|
||||
expose/remove translation fields across all translatable objects, generically —
|
||||
never per-field hardcoding.
|
||||
- Static pages (About Us, Privacy, Terms, Contacts, Return Policy, Delivery,
|
||||
custom pages) are backend-delivered HTML, multilingual, and drive the footer.
|
||||
- Admin and storefront share a domain but are fully separate applications: the
|
||||
marketplace bundle never ships admin code and vice versa. Bootstrap is public;
|
||||
Admin is protected by JWT + roles/permissions + tenant isolation (Super Admin,
|
||||
Marketplace Admin, Moderator, Editor, Support, Customer).
|
||||
|
||||
## Coding rules
|
||||
|
||||
- Never hardcode marketplace data or introduce marketplace-specific conditionals.
|
||||
- Never use environment flags to drive UI — everything is config-driven.
|
||||
- Keep components small; prefer composition and reusable widgets; never
|
||||
duplicate layouts.
|
||||
- Business logic lives in services/facades, not components.
|
||||
- Prefer Signals and standalone components.
|
||||
- Every new feature ships with docs: frontend docs, backend contract, bootstrap
|
||||
updates, API examples, migration notes if needed.
|
||||
|
||||
## Guiding question
|
||||
|
||||
Before implementing anything: "Will this still make sense after 50 marketplaces
|
||||
and 100 developers?" If not, redesign before coding.
|
||||
|
||||
## Consequences
|
||||
|
||||
Any feature (including the Sprint 16 Project Editor) must edit the same Bootstrap
|
||||
model the storefront consumes — no parallel/duplicate configuration models are
|
||||
permitted anywhere in the platform.
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
id: ADR-0002
|
||||
title: Media Manager backend contract and mock storage adapter
|
||||
status: active
|
||||
date: 2026-07-15
|
||||
tags: ["architecture", "media", "backend-gap", "repository-pattern"]
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Sprint 4 (Media Manager) needs a media library: upload, browse, delete, and pick
|
||||
images/files for use across Product Editor, Static Pages (CMS), and Branding.
|
||||
No media backend exists yet — `/media` currently routes to a "coming soon"
|
||||
placeholder (`BackofficeComingSoonPageComponent`), and `docs/BACKEND.md` does
|
||||
not document any upload/storage endpoint. This mirrors the already-documented
|
||||
draft-publish-flow gap in the Project Editor (see `PE-20260713T010000Z-0003`):
|
||||
build the real contract, then implement a client-side mock adapter behind the
|
||||
same interface so the UI never needs to change when the backend ships.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Domain model** `MediaAsset`: `{ id, url, thumbnailUrl?, filename, mimeType,
|
||||
size, width?, height?, altText?: Record<locale, string>, tags?: string[],
|
||||
createdAt }`. `altText` follows the platform's `translations.{lang}` rule
|
||||
(ADR-0001) — never a flat string.
|
||||
- **Repository contract** (future backend, to be implemented server-side):
|
||||
- `GET /media?page=&pageSize=&search=` → paginated `MediaAsset[]`
|
||||
- `POST /media/upload` (multipart) → `MediaAsset`
|
||||
- `DELETE /media/:id` → 204
|
||||
- `PATCH /media/:id` (altText/tags only) → `MediaAsset`
|
||||
- **Frontend abstraction**: a `MediaRepository` interface (Repository pattern,
|
||||
per `docs/context/features/*` conventions) with two implementations selected
|
||||
via DI token:
|
||||
- `MockMediaRepository` — stores assets in IndexedDB (not localStorage: binary
|
||||
blobs need it) as an interim store until the backend exists. Data URLs are
|
||||
generated for rendering; the shape returned matches `MediaAsset` exactly.
|
||||
- `HttpMediaRepository` — thin wrapper over the endpoints above, added when
|
||||
the backend ships. Swapping providers is the only change required.
|
||||
- **Media never enters the Bootstrap model.** Like products/orders/users, media
|
||||
assets are runtime admin data, not tenant configuration — consistent with
|
||||
ADR-0001's rule that Bootstrap contains only what's needed before the app
|
||||
starts.
|
||||
- **Media Picker** is a standalone, reusable dialog (built on the existing
|
||||
`app-dialog` Design System primitive) so Product Editor and CMS editors
|
||||
consume the same selection UI instead of each building their own.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Any feature needing to reference an image (product gallery, static page
|
||||
hero, branding logo) does so via `MediaAsset.url`/`id`, obtained through the
|
||||
shared Media Picker — never a raw file input duplicated per feature.
|
||||
- When the backend ships, only `MediaRepository`'s DI provider changes; no
|
||||
component or facade code should need to change.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
id: ADR-0002
|
||||
title: Project Editor field-schema registry, centralized validation, and metadata-augmented form engine
|
||||
status: active
|
||||
date: 2026-07-16
|
||||
tags: ["project-editor", "schema", "validation", "undo-redo"]
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Project Editor (`src/app/features/project-editor/`) edits the tenant `BootstrapConfig` across 11 hand-authored section templates, all built on the `shared/ui` field primitives (ADR established post-Sprint 30 redesign; see `docs/EDITOR.md`). Field labels/hints/defaults lived inline per template, `ProjectValidator` issues were not addressable to a field, there was no undo/redo, and no per-field modified/error state. Sprint X+1 ("Configuration Engine & Dynamic Form Foundation") required: a field-schema registry, centralized validation (JSON/CSS/URL/color/locale/duplicate-route/widget-config), live inline validation with publish-gating, pre-publish preview, dirty/modified-field tracking with a leave-warning, and session undo/redo — without duplicating form logic or validators, and without breaking draft/publish/import/export.
|
||||
|
||||
## Decision
|
||||
|
||||
**Metadata-augmented, not fully schema-driven.** A field-schema registry (`schema/field-schema.model.ts`, `schema/editor-schema.ts`, `schema/editor-schema.service.ts`) declares every editable field (dot-path key, section, type, label/hint keys, default, required, validator refs) as the single source of truth for field identity and validator wiring — but section templates stay hand-authored. The schema drives validation and metadata; it does not render fields. This was chosen over a fully schema-driven renderer because 11 mature templates already exist on top of the `shared/ui` kit, and a renderer rewrite carried materially higher regression risk against "preserve all existing functionality" for no UX gain.
|
||||
|
||||
**Validators are pure, composed, and tagged.** `schema/validators/primitives.ts` holds one pure function per concern (hex color, HTTP URL, email, JSON, CSS brace-balance, style-block extraction, route normalization). `ProjectValidator` composes them and attaches `section`, `fieldKey`, and `severity` (`error` | `warning`) to every issue, so the same validator is never re-implemented per field or per section.
|
||||
|
||||
**Severity splits blocking from advisory.** `publish()` now gates on `hasBlockingIssues()` (`severity === 'error'`) instead of "any issue exists." All 9 pre-existing checks stayed `error` (no behavior change); the new duplicate-routes and invalid-CSS checks are `warning` — informative, non-blocking, by design.
|
||||
|
||||
**Undo/redo is a pure reducer wrapped in debounced facade state.** `schema/history.util.ts` is a framework-free `{past, future}` snapshot reducer (commit/undo/redo, depth-capped). The facade debounces commits (~300ms) so a typing burst collapses into one undo step, and routes undo/redo through the same `localStorage` draft-save path as every other mutation so the autosave never desyncs from the undo stack.
|
||||
|
||||
**Modified-field tracking is a schema diff, not a form-state library.** `modifiedFields` walks every schema field and compares current vs. `originalBootstrap` by dot-path — no new dependency, reuses `EditorSchemaService.getByPath`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
- One registry answers "what fields exist, what validates them, what do they mean" — new fields register once and get validation + inline-error wiring for free.
|
||||
- No validator is duplicated: JSON/CSS/color/URL/email logic lives in exactly one place each.
|
||||
- Zero changes to `ProjectEditorIoService`, `ProjectEditorDraftStorageService`, or the draft/publish/reset flow — full backward compatibility.
|
||||
- Undo/redo and modified-field tracking added without a state-management library.
|
||||
|
||||
Negative / accepted debt:
|
||||
- Inline `[error]` binding is wired on a subset of fields (theme palette, general name/domain, branding logo) — not yet every schema-backed field across all 11 sections. Section-level visibility (nav badges, save-bar issue list) covers the rest today.
|
||||
- The field-schema registry is not yet consumed by templates for label/hint rendering (still inline i18n keys in each template) — only for validation, diffing, and change-summary labels. A future pass could fully drive labels from the schema.
|
||||
- CSS/JSON validators have a thin binding surface today (CSS only via static-page `<style>` blocks; JSON only via import) since no dedicated `customCss`/raw-JSON field exists yet in `BootstrapConfig`.
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
- New editable `BootstrapConfig` fields should get a `FieldSchema` entry in `editor-schema.ts` alongside their template addition.
|
||||
- New validation rules must be added as a pure function in `schema/validators/primitives.ts` and composed into `ProjectValidator` — never inlined ad hoc in a section component.
|
||||
- `severity: 'error'` is reserved for checks that must block Publish; anything advisory is `'warning'`.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: ADR-0003
|
||||
title: Build partner merchant-provisioning as a generic API, not a per-partner integration
|
||||
status: active
|
||||
date: 2026-08-18
|
||||
supersedes: []
|
||||
tags: [architecture, api, payments, multi-tenant, security, decision]
|
||||
---
|
||||
|
||||
# ADR-0003: Build partner merchant-provisioning as a generic API, not a per-partner integration
|
||||
|
||||
## Context
|
||||
|
||||
A partner asked (2026-08-18) for an API to programmatically manage a merchant hierarchy — Company → Project → Store → PaymentPoint — with idempotent provisioning, `externalReference` lookup, TEST/LIVE separation, public-key credentials with scoped authority and rotation, and payment/callback fields that route a payment unambiguously to one store.
|
||||
|
||||
Their request arrived written in their own vocabulary. Building against that vocabulary directly would produce a partner-shaped API, and the next partner asking for the same capability with different level names would either get a second parallel surface or force a rename through our schema.
|
||||
|
||||
Three facts about our current model made the ask non-trivial:
|
||||
|
||||
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/BACKEND-INTEGRATION.md)). No company, no project.
|
||||
2. Payments carry no store dimension ([Phase 1](../../backend/BACKEND-INTEGRATION.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
|
||||
3. We have no partner-facing write API at all. [Phase 4](../../backend/BACKEND-INTEGRATION.md) is outbound/ingest — the opposite direction.
|
||||
|
||||
## Decision
|
||||
|
||||
Build one generic partner provisioning API. Contract: [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md).
|
||||
|
||||
### 1. Partner-specific behaviour is config, never schema
|
||||
|
||||
No partner name appears in any entity, field, endpoint, or status value. Everything partner-varying lives in a `PartnerProfile` row: which levels are required, level name aliases, routing field names, rate-limit tier, key rotation window, webhook field map. **Onboarding a partner is a config row, not a deployment.**
|
||||
|
||||
Deliberately *not* configurable, because configurability there breaks reconciliation or safety: status values and transitions, idempotency semantics, environment partitioning, signature scheme, the four-level ceiling.
|
||||
|
||||
### 2. Fixed four levels with optional middles, not a free-form tree
|
||||
|
||||
`company → project → store → payment_point`. Middle levels are omittable per partner profile; depth is never partner-defined. An arbitrary-depth tree would push every downstream consumer — routing, reconciliation, settlement, audit — into handling shapes no partner actually has.
|
||||
|
||||
### 3. Credentials are node-scoped
|
||||
|
||||
The partner asked us to choose between per-company, per-project, and per-store credentials. We answer all three with one mechanism: a credential binds to **any single node**, and its authority is that node's subtree. Partner keypairs are partner-generated; we hold only the public key. Rotation runs with a bounded overlap; revocation is immediate and irreversible.
|
||||
|
||||
### 4. Level mapping onto our model
|
||||
|
||||
| Partner level | Our entity |
|
||||
|---|---|
|
||||
| `company` | new, thin |
|
||||
| `project` | new, thin — a product line (e.g. `marketplaces`) |
|
||||
| `store` | `Marketplace` (Phase 9), gains `companyId`/`projectId`/`externalReference` |
|
||||
| `payment_point` | new — one payment method accepted at one marketplace (`qr`, `card`; both ship today) |
|
||||
|
||||
`PaymentPoint` is an acceptance channel, not a physical till and not a settlement account. Registering one never enables real money — financial enablement is a separate approved flow that sets `providerAccountRef`.
|
||||
|
||||
### 5. Seller is excluded from the hierarchy
|
||||
|
||||
`Seller` ([Phase 5](../../backend/BACKEND-INTEGRATION.md)) is orthogonal. A payment routes to one payment point, is reconciled there, and only then splits across the sellers whose lines the order contains ([Phase 7](../../backend/BACKEND-INTEGRATION.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
|
||||
|
||||
### 6. RoutingContext lands in Phase 1 before implementation, not after
|
||||
|
||||
`RoutingContext` (companyId, routingPath, leafNodeId, environment, merchantReference, providerPaymentId) is required on `CheckoutSession`, `PaymentIntent`, `Payment`, `Refund`, `ReconciliationRecord`. Frozen at checkout-session creation, immutable thereafter.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Cost now:** two new entities (`Company`, `Project`), one new leaf (`PaymentPoint`), three amended contracts (Phases 1, 7, 9) plus Track S §4.1, and a backfill for existing marketplaces (Phase 9 §1.2).
|
||||
|
||||
**Cost avoided:** retrofitting a routing dimension onto a populated payments table after launch; a second parallel provisioning surface for partner number two.
|
||||
|
||||
**Accepted limits:**
|
||||
- A partner needing more than four levels cannot be served without a contract change. Judged unlikely enough to be worth the simplicity.
|
||||
- Backfilled rows carry a synthetic company and project. `externalReference` stays null for them.
|
||||
- Partners cannot create companies through the API — company creation stays a commercial, out-of-band action.
|
||||
|
||||
**Unaffected:** the `@marketplaces/auth` / `@marketplaces/payment` package split ([ADR-0001](ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md)). The provisioning API is backend-side; nothing about it belongs in a frontend package.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: ADR-0004
|
||||
title: Derive each API host from the complete storefront host
|
||||
status: superseded
|
||||
date: 2026-08-20
|
||||
supersedes: []
|
||||
tags: [architecture, multi-tenant, api, routing, dns]
|
||||
superseded_by: [ADR-0005]
|
||||
---
|
||||
|
||||
# ADR-0004: Derive each API host from the complete storefront host
|
||||
|
||||
> Superseded by [ADR-0005](ADR-0005-share-api-host-across-storefront-subdomains.md).
|
||||
|
||||
## Context
|
||||
|
||||
One production bundle serves root domains and arbitrary storefront subdomains.
|
||||
The old bundle embedded `api.dexarmarket.ru`, while an earlier correction used
|
||||
a same-origin `/backend` gateway. Neither expresses the required domain rule:
|
||||
each storefront has a corresponding API hostname derived from its full host.
|
||||
|
||||
Examples:
|
||||
|
||||
- `example.com` uses `api.example.com`.
|
||||
- `store1.example.com` uses `api.store1.example.com`.
|
||||
|
||||
Bootstrap, auth, legacy endpoints, and versioned endpoints must not use
|
||||
different base-host selection rules.
|
||||
|
||||
## Decision
|
||||
|
||||
At runtime the frontend prefixes the complete browser hostname with `api.` and
|
||||
keeps the browser protocol: `{protocol}//api.{hostname}`.
|
||||
|
||||
- Bootstrap loads from `https://api.{hostname}/bootstrap`.
|
||||
- Auth receives the same derived base through `AUTH_API_URL`.
|
||||
- Legacy endpoints append their existing paths to that base.
|
||||
- Versioned `/api/...` endpoints retain the `/api` prefix.
|
||||
- Localhost and loopback continue to use the local `/api` development proxy.
|
||||
- An explicit `tenantApiBaseUrls` entry may override the convention for an
|
||||
exceptional host, without changing the shared bundle.
|
||||
|
||||
The complete hostname is preserved. In particular, `www.example.com` maps to
|
||||
`api.www.example.com`; no label is stripped or interpreted by the frontend.
|
||||
|
||||
## Consequences
|
||||
|
||||
One artifact works on root domains and nested storefront subdomains without a
|
||||
tenant allowlist or per-domain build. Every API hostname must have DNS, TLS, a
|
||||
working reverse proxy, and CORS configured for its corresponding storefront.
|
||||
|
||||
A wildcard such as `*.example.com` does not cover the multi-label hostname
|
||||
`api.store1.example.com`; nested API names need explicit certificates/DNS or a
|
||||
certificate and routing strategy that covers that depth. Backend tenant lookup
|
||||
must recognize `api.<storefront-host>` as the API alias of `<storefront-host>`.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: ADR-0005
|
||||
title: Share one API host across storefront subdomains
|
||||
status: active
|
||||
date: 2026-08-20
|
||||
supersedes: [ADR-0004]
|
||||
tags: [architecture, multi-tenant, api, routing, dns]
|
||||
---
|
||||
|
||||
# ADR-0005: Share one API host across storefront subdomains
|
||||
|
||||
## Context
|
||||
|
||||
One frontend bundle serves a base storefront domain and tenant subdomains. The
|
||||
API is shared at the base-domain level; a tenant subdomain must not create a
|
||||
nested API hostname.
|
||||
|
||||
## Decision
|
||||
|
||||
- `example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
`https://api.example.com`.
|
||||
- The complete storefront hostname remains the tenant hint. nginx validates the
|
||||
browser Origin and forwards that hostname as `X-Storefront-Host`.
|
||||
- Backend tenant lookup trusts that header only from the known proxy, verifies
|
||||
it against the domain registry, and binds authenticated sessions to the same
|
||||
tenant.
|
||||
- Localhost continues through `/api`. `tenantApiBaseUrls` remains available for
|
||||
public-suffix or custom-domain exceptions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tenant subdomains need no extra API DNS records or certificates. CORS must echo
|
||||
the exact allowed storefront origin, while unknown or disabled domains still
|
||||
receive `403` from the backend. The shared API `Host` alone cannot identify a
|
||||
subdomain tenant.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
id: ADR-0006
|
||||
title: Harvest mechanisms from the parallel platform, keep our architecture
|
||||
status: active
|
||||
date: 2026-08-21
|
||||
tags: [architecture, security, contracts, platform, governance]
|
||||
---
|
||||
|
||||
# ADR-0006: Harvest mechanisms from the parallel platform, keep our architecture
|
||||
|
||||
## Context
|
||||
|
||||
A second team built a competing platform monorepo — NestJS/Fastify API, PostgreSQL/Prisma, two Angular apps, Docker/Nginx infrastructure — sharing an older `dexarmarket` ancestor with this repo. On 2026-08-11 they received a snapshot of our code, audited it, and vendored it into their tree as `reference/parallel-frontend/`, classified as a UI/UX reference rather than production. Their handoff document ranks our work fifth of five priority sources.
|
||||
|
||||
Full comparison: [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md).
|
||||
|
||||
The asymmetry is real and runs both ways. They have working server-side truth: tenancy resolved from a verified `Host`, RBAC enforced per endpoint, hashed server sessions with mandatory TOTP, encrypted per-tenant payment credentials, idempotent webhooks, immutable publish revisions, WAL archiving and a restore check. We have the deeper frontend — 530 `.ts` files against 189, 158 components, 30 spec files plus Playwright e2e against their 25 unit tests and no e2e at all, Angular 22 with a clean production audit against their 21.2.18 with open high findings, and architecture governance in CI that they have no equivalent of.
|
||||
|
||||
Three of their audit findings against us were still live when re-checked on 2026-08-21, and two of them were defects rather than posture: a plaintext `ip-api.com` call that mixed-content blocking had silently killed in production, and an unvalidated bank URL rendered into an iframe that most acquirers refuse to be framed in.
|
||||
|
||||
## Decision
|
||||
|
||||
Take the mechanisms. Do not take the architecture, and do not merge the codebases.
|
||||
|
||||
- **Harvest** specific, proven mechanisms into our backend contracts under a traceable `FH-*` tag: conditional-write stock reservation, idempotency as a unique constraint, hashed server sessions with per-contour cookies, an origin allowlist on cookie-authenticated mutations, an AES-256-GCM envelope for stored secrets, signed read-only preview, revision immutability and clone semantics, an append-only inventory journal, server-side content validation, and digital code pools.
|
||||
- **Reject** anything that would regress us: their Angular version, their mock service still shipping in a backoffice, their test posture, their environment-pinned manager scope, their hardcoded server IP, and their narrower section schema.
|
||||
- **Keep ours where ours is better** and say so explicitly, so it does not get relitigated: our marketplace lifecycle state machine is richer than theirs, our bulk-import preview/apply flow is equivalent, our editor validation engine is stronger — it simply needs a server-side counterpart to bind.
|
||||
- **Record the nine invariants** from their handoff as the acceptance gate at the head of our own backend handoff, since they are more falsifiable than anything our delivery plan had.
|
||||
|
||||
## Consequences
|
||||
|
||||
The backend contracts gain normative mechanism text where they previously stated intent, which raises the bar a backend built against them must clear — at the cost of more prescription than these documents originally carried. That trade is deliberate: "the webhook must be idempotent" survives one refactor, `UNIQUE (provider, event_key)` survives every refactor.
|
||||
|
||||
Our repo stays frontend-only. Nothing harvested requires standing up Prisma or NestJS here; anything that would have becomes a contract line instead. Work splits across five lanes — frontend, contracts, the `@marketplaces/auth` package, infrastructure, and process — tracked in [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md).
|
||||
|
||||
Adopting their invariants and their PR and release discipline as our own means our releases get slower and more evidenced. That is the intended direction.
|
||||
|
||||
The organizational question this ADR does not settle: whether the two implementations converge as their backend plus our frontend. Left unchallenged, their handoff document's ranking becomes the plan of record by default.
|
||||
@@ -4,3 +4,11 @@
|
||||
{"id":"PV-20260713T000000Z-0004","subject":"translatable-fields","predicate":"must-be-modeled-as","object":"generic translations.{lang} map so adding/removing a language automatically exposes/removes translation fields across all translatable objects","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["i18n","constraint"]}
|
||||
{"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]}
|
||||
{"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]}
|
||||
{"id":"PV-20260818T001500Z-a1f3","subject":"auth-and-payment-client-logic","predicate":"is-decided-to-extract-into","object":"standalone versioned npm packages @marketplaces/auth and @marketplaces/payment, installed as dependencies rather than edited in-repo","src":["docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T00:15:00Z","confidence":"high","tags":["architecture","auth","payment","decision"]}
|
||||
{"id":"PV-20260818T104000Z-c7d1","subject":"partner-merchant-provisioning","predicate":"is-decided-to-build-as","object":"one generic inbound API where all partner-specific behaviour is a PartnerProfile config row (required levels, level aliases, routing field names, rate tier); no partner name appears in any entity, field, endpoint or status value","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:40:00Z","confidence":"high","tags":["architecture","api","partner","decision"]}
|
||||
{"id":"PV-20260818T104100Z-e2b8","subject":"partner-hierarchy-levels","predicate":"map-onto","object":"company and project are new thin entities above Marketplace; store IS Marketplace (Phase 9); payment_point is new and equals one payment method accepted at one marketplace (qr, card)","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:41:00Z","confidence":"high","tags":["architecture","multi-tenant","payments","decision"]}
|
||||
{"id":"PV-20260818T104200Z-f5a9","subject":"Seller","predicate":"is-excluded-from","object":"the partner provisioning hierarchy; a payment routes to exactly one payment point, is reconciled there, and only then splits across sellers in Phase 7 settlement","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:42:00Z","confidence":"high","tags":["payments","reconciliation","sellers"]}
|
||||
{"id":"PV-20260818T104300Z-b3c4","subject":"RoutingContext","predicate":"is-required-on","object":"CheckoutSession, PaymentIntent, Payment, Refund and ReconciliationRecord; frozen at checkout-session creation and immutable thereafter, so a payment is always attributable to exactly one payment point","src":["docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:43:00Z","confidence":"high","tags":["payments","reconciliation","contract"]}
|
||||
{"id":"PV-20260818T104400Z-d9e2","subject":"partner-api-credentials","predicate":"are-scoped-by","object":"a single node whose subtree defines authority; we hold only the partner-generated public key, rotation runs on a bounded overlap window and revocation is immediate and irreversible","src":["docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md","docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:44:00Z","confidence":"high","tags":["security","credentials","partner"]}
|
||||
{"id":"PV-20260818T104500Z-a6f7","subject":"checkout-payment-methods","predicate":"already-support","object":"both qr and card end to end in src/app/pages/cart/cart.component.ts (separate create paths and separate status pollers); card is not an outstanding gap","src":["src/app/pages/cart/cart.component.ts","src/app/services/api.service.ts"],"status":"active","kind":"implemented","updated_at":"2026-08-18T10:45:00Z","confidence":"high","tags":["payments","frontend"]}
|
||||
{"id":"PV-20260820T095500Z-b17e","subject":"tenant-api-routing","predicate":"is-decided-to-use","object":"one runtime-derived API origin per base domain; example.com and store1.example.com both map to api.example.com for bootstrap, auth, legacy, and versioned endpoints","src":["docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md","src/app/core/config/api-config.service.ts"],"status":"active","kind":"decision","updated_at":"2026-08-20T16:00:00Z","confidence":"high","tags":["architecture","multi-tenant","api","routing","dns"]}
|
||||
|
||||
225
docs/superpowers/plans/2026-08-15-admin-product-views-column.md
Normal file
225
docs/superpowers/plans/2026-08-15-admin-product-views-column.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Admin Product Views Column Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show a real (currently-zero) per-product view count as a toggleable column in Admin Products list, and document the backend gap that keeps it at zero today.
|
||||
|
||||
**Architecture:** Add `visits: number` to the `AdminProduct` model, default it to `0` everywhere the mock gateway constructs an `AdminProduct`, add `'visits'` to the existing toggleable-column system (`ALL_PRODUCT_COLUMNS`), render it in the table view using the established `isColumnVisible()` pattern.
|
||||
|
||||
**Tech Stack:** Angular signals, existing `LocalStorageService`-backed column-visibility persistence (already built, not touched).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Never fabricate view numbers — the mock gateway has no real tracking source, so `visits` must default to `0`, not a random/seeded number.
|
||||
- Table view only — no grid-view or product-detail-page display (out of scope per design doc).
|
||||
- Follow the existing `isColumnVisible('stock')`-style pattern exactly — no new column-visibility mechanism.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `visits` field, column, and backend doc ask
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/products/models/admin-product.model.ts:81-121` (add field)
|
||||
- Modify: `src/app/features/admin/products/services/admin-products-local.gateway.ts:82-94,133-166` (default the field)
|
||||
- Modify: `src/app/features/admin/products/facade/admin-products.facade.ts:39` (add to column list)
|
||||
- Modify: `src/app/features/admin/products/components/admin-products-list.component.html:96-112` (render column + header)
|
||||
- Modify: `src/app/i18n/en.ts:1647,1670`, `src/app/i18n/ru.ts:1642`, `src/app/i18n/hy.ts:1642`, `src/app/i18n/translations.ts:1655` (i18n keys)
|
||||
- Modify: `BACKEND-API-REFERENCE.md` (new §12.10 ask)
|
||||
- Test: `src/app/features/admin/products/services/admin-products-local.gateway.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AdminProduct.visits: number`
|
||||
- Produces: `ALL_PRODUCT_COLUMNS` includes `'visits'` (so `AdminProductColumn` union includes `'visits'`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/products/services/admin-products-local.gateway.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { AdminProductsLocalGateway } from './admin-products-local.gateway';
|
||||
|
||||
describe('AdminProductsLocalGateway visits field', () => {
|
||||
let gateway: AdminProductsLocalGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
gateway = TestBed.inject(AdminProductsLocalGateway);
|
||||
});
|
||||
|
||||
it('defaults visits to 0 on every loaded product', (done) => {
|
||||
gateway.loadProducts({ search: '', categoryId: 'all', visibility: 'all', stockStatus: 'all', page: 1, pageSize: 50 }).subscribe(result => {
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every(product => product.visits === 0)).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets visits to 0 on a duplicated product, even if the source had a nonzero count', (done) => {
|
||||
gateway.loadProducts({ search: '', categoryId: 'all', visibility: 'all', stockStatus: 'all', page: 1, pageSize: 50 }).subscribe(result => {
|
||||
const source = result.items[0];
|
||||
gateway.duplicateProduct(source.id).subscribe(duplicated => {
|
||||
expect(duplicated).not.toBeNull();
|
||||
expect(duplicated!.visits).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: read `src/app/features/admin/products/models/admin-product.model.ts` for the exact `AdminProductListFilters` shape before writing the test's filter object — if the field names above (`categoryId`, `visibility`, `stockStatus`, `page`, `pageSize`) don't match exactly, use the real ones; don't guess.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
|
||||
Expected: FAIL — `Property 'visits' does not exist on type 'AdminProduct'` (TS compile error surfaces as a Karma failure).
|
||||
|
||||
- [ ] **Step 3: Add the field to the model**
|
||||
|
||||
In `src/app/features/admin/products/models/admin-product.model.ts`, add to the `AdminProduct` interface (next to `quantity: number;`):
|
||||
|
||||
```typescript
|
||||
quantity: number;
|
||||
visits: number;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Default it in the mock gateway**
|
||||
|
||||
In `src/app/features/admin/products/services/admin-products-local.gateway.ts`, in `toAdminProduct()` (around line 155, next to the `quantity` line):
|
||||
|
||||
```typescript
|
||||
quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25,
|
||||
visits: 0,
|
||||
```
|
||||
|
||||
In `duplicateProduct()` (around line 82-91), add `visits: 0` to the override object so a duplicate never inherits the source's count via the `...source` spread:
|
||||
|
||||
```typescript
|
||||
const duplicated: AdminProduct = {
|
||||
...source,
|
||||
archived: false,
|
||||
id: `${source.id}-copy-${Date.now()}`,
|
||||
sku: `${source.sku}-COPY`,
|
||||
slug: `${source.slug}-copy-${Date.now()}`,
|
||||
name: `${source.name} Copy`,
|
||||
visits: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
|
||||
Expected: PASS (2/2)
|
||||
|
||||
- [ ] **Step 6: Add the column**
|
||||
|
||||
In `src/app/features/admin/products/facade/admin-products.facade.ts:39`, change:
|
||||
|
||||
```typescript
|
||||
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated', 'visits'] as const;
|
||||
```
|
||||
|
||||
In `src/app/features/admin/products/components/admin-products-list.component.html`, add a header cell after the `visibility` header (around line 100):
|
||||
|
||||
```html
|
||||
@if (isColumnVisible('visibility')) { <th scope="col">{{ 'adminProducts.visibility' | translate }}</th> }
|
||||
@if (isColumnVisible('visits')) { <th scope="col">{{ 'adminProducts.views' | translate }}</th> }
|
||||
```
|
||||
|
||||
And a matching body cell after the `visibility` cell (around line 126, right after its closing `}`):
|
||||
|
||||
```html
|
||||
@if (isColumnVisible('visits')) { <td>{{ product.visits }}</td> }
|
||||
```
|
||||
|
||||
The column-picker panel (`admin-products-list.component.html:50-59`) needs no template change — it already iterates `allColumns` generically and looks up `adminProducts.column_<name>`, so it auto-picks up `'visits'` once the i18n key exists (Step 7).
|
||||
|
||||
- [ ] **Step 7: Add i18n keys**
|
||||
|
||||
In `src/app/i18n/translations.ts`, in the `adminProducts` interface block, add two lines (next to `stockStatus: string;` and near the other `column_*` entries):
|
||||
|
||||
```typescript
|
||||
stockStatus: string;
|
||||
views: string;
|
||||
```
|
||||
```typescript
|
||||
column_updated: string;
|
||||
column_visits: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, `adminProducts` block:
|
||||
```typescript
|
||||
stockStatus: 'Stock status',
|
||||
views: 'Views',
|
||||
```
|
||||
```typescript
|
||||
column_updated: 'Last updated',
|
||||
column_visits: 'Views',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, `adminProducts` block (next to its `stockStatus:` line and its `column_updated:` line — read the file first to find them, they're at different line numbers than en.ts):
|
||||
```typescript
|
||||
views: 'Просмотры',
|
||||
```
|
||||
```typescript
|
||||
column_visits: 'Просмотры',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, `adminProducts` block:
|
||||
```typescript
|
||||
views: 'Դիտումներ',
|
||||
```
|
||||
```typescript
|
||||
column_visits: 'Դիտումներ',
|
||||
```
|
||||
|
||||
(For ru.ts/hy.ts: read the file first, find the exact existing `stockStatus:`/`column_updated:` lines in the `adminProducts` block — there may be more than one `column_updated:` in the file for a different admin domain, only edit the one inside `adminProducts`, at the location already found: `ru.ts:1642` area, `hy.ts:1642` area.)
|
||||
|
||||
- [ ] **Step 8: Run full verification**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no errors.
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds.
|
||||
|
||||
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
|
||||
Expected: PASS (2/2).
|
||||
|
||||
- [ ] **Step 9: Document the backend gap**
|
||||
|
||||
In `BACKEND-API-REFERENCE.md`, after the existing §12.8 section (search for `### 12.8 Admin purchase notifications depend on Orders CRUD being real` — it currently ends right before `### 12.9 Trending search terms`), insert a new section, and renumber `12.9` to `12.10`:
|
||||
|
||||
```markdown
|
||||
### 12.9 Admin product view counts
|
||||
|
||||
**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI.
|
||||
|
||||
**Ask:** two options, not mutually exclusive:
|
||||
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
|
||||
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
|
||||
|
||||
### 12.10 Trending search terms
|
||||
```
|
||||
|
||||
(The existing body text of the old `### 12.9 Trending search terms` section stays exactly as-is below the renumbered heading — only the heading number changes, from `12.9` to `12.10`.)
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/products/models/admin-product.model.ts src/app/features/admin/products/services/admin-products-local.gateway.ts src/app/features/admin/products/services/admin-products-local.gateway.spec.ts src/app/features/admin/products/facade/admin-products.facade.ts src/app/features/admin/products/components/admin-products-list.component.html src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts src/app/i18n/translations.ts BACKEND-API-REFERENCE.md
|
||||
git commit -m "feat: admin product views column (always 0 until backend tracks it)"
|
||||
```
|
||||
@@ -0,0 +1,944 @@
|
||||
# Admin Purchase Notifications Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Notify admin (toast + topbar bell badge/panel) when a new order lands on the marketplace, poll-based since the backend has no WebSocket/SSE.
|
||||
|
||||
**Architecture:** A single `AdminOrderWatcherService` polls `AdminOrdersLocalGateway.loadOrders()` on an editable interval (default 15s), diffs against a persisted "last notified" order id to fire toasts for genuinely new orders, and exposes a `recentOrders`/`unreadCount` signal pair that the existing (currently-empty) topbar bell panel renders. Poll interval is editable in the admin settings page, same pattern as the currency-rates section added previously.
|
||||
|
||||
**Tech Stack:** Angular 17+ signals, RxJS, Jasmine/Karma (`ng test`), existing `LocalStorageService`/`UserNotificationService`/`TranslateService` patterns.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No WebSocket/SSE available — polling only (confirmed `BACKEND-API-REFERENCE.md:20`).
|
||||
- Persist state via `LocalStorageService` (`getItem`/`setItem`), never raw `localStorage`.
|
||||
- Reuse the existing topbar bell (`admin-layout.component.html:141-157`) instead of adding a new nav badge.
|
||||
- Admin backoffice price/amount displays stay in the order's raw stored currency (no `currencyConvert` pipe) — consistent with every other admin screen.
|
||||
- Route arrays for admin navigation use the pattern `[languageService.currentLanguage(), 'backoffice', 'orders', id]` (no leading `/`), matching `admin-orders-list-page.component.ts:52`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `UserNotificationService` gains an optional click-to-navigate route
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/website/user-experience/services/user-notification.service.ts`
|
||||
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts`
|
||||
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`
|
||||
- Test: `src/app/features/website/user-experience/services/user-notification.service.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `UserNotificationService.show(message: string, type?: UserNotificationType, durationMs?: number, route?: string[]): void`
|
||||
- Produces: `UserNotification.route?: string[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/website/user-experience/services/user-notification.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
|
||||
describe('UserNotificationService', () => {
|
||||
let service: UserNotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UserNotificationService);
|
||||
});
|
||||
|
||||
it('stores the route on the notification when provided', () => {
|
||||
service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']);
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.message).toBe('New order #1042');
|
||||
expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']);
|
||||
});
|
||||
|
||||
it('leaves route undefined when not provided', () => {
|
||||
service.show('Saved');
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.route).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
|
||||
Expected: FAIL — `show` has no fourth parameter, `route` does not exist on `UserNotification`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the full contents of `src/app/features/website/user-experience/services/user-notification.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type UserNotificationType = 'success' | 'info' | 'warning';
|
||||
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
message: string;
|
||||
type: UserNotificationType;
|
||||
/** Route to navigate to when the notification is clicked. Absent means not clickable. */
|
||||
route?: string[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserNotificationService {
|
||||
private readonly state = signal<UserNotification[]>([]);
|
||||
|
||||
readonly notifications = this.state.asReadonly();
|
||||
|
||||
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void {
|
||||
const next: UserNotification = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
message,
|
||||
type,
|
||||
...(route ? { route } : {}),
|
||||
};
|
||||
|
||||
this.state.update(items => [next, ...items].slice(0, 4));
|
||||
|
||||
setTimeout(() => this.dismiss(next.id), durationMs);
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.state.update(items => items.filter(item => item.id !== id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Modify `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts` — replace full contents:
|
||||
|
||||
```typescript
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { UserNotification, UserNotificationService } from '../../services/user-notification.service';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-notifications',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './floating-notifications.component.html',
|
||||
styleUrls: ['./floating-notifications.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingNotificationsComponent {
|
||||
private readonly notificationsService = inject(UserNotificationService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly notifications = this.notificationsService.notifications;
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.notificationsService.dismiss(id);
|
||||
}
|
||||
|
||||
navigate(note: UserNotification): void {
|
||||
if (note.route) {
|
||||
void this.router.navigate(note.route);
|
||||
}
|
||||
this.dismiss(note.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace full contents of `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`:
|
||||
|
||||
```html
|
||||
@if (notifications().length > 0) {
|
||||
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
||||
@for (note of notifications(); track note.id) {
|
||||
<article
|
||||
class="floating-note"
|
||||
[class]="'floating-note floating-note-' + note.type"
|
||||
[class.floating-note-clickable]="!!note.route"
|
||||
(click)="note.route && navigate(note)"
|
||||
>
|
||||
<p>{{ note.message }}</p>
|
||||
<button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
||||
</article>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
|
||||
Expected: PASS (2 specs)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/website/user-experience/services/user-notification.service.ts src/app/features/website/user-experience/services/user-notification.service.spec.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
|
||||
git commit -m "feat: UserNotificationService supports click-to-navigate toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `AdminOrderWatcherService` — polling, diffing, toast firing
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/features/admin/shell/services/admin-order-watcher.service.ts`
|
||||
- Test: `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`
|
||||
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts` (one new key)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrdersLocalGateway.loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>` (existing)
|
||||
- Consumes: `UserNotificationService.show(message, type?, durationMs?, route?)` (Task 1)
|
||||
- Consumes: `LocalStorageService.getItem(key): string | null`, `.setItem(key, value): void` (existing)
|
||||
- Produces: `AdminOrderWatcherService.recentOrders: Signal<AdminOrder[]>`
|
||||
- Produces: `AdminOrderWatcherService.unreadCount: Signal<number>`
|
||||
- Produces: `AdminOrderWatcherService.intervalMs: Signal<number>`
|
||||
- Produces: `AdminOrderWatcherService.start(): void`
|
||||
- Produces: `AdminOrderWatcherService.markAllSeen(): void`
|
||||
- Produces: `AdminOrderWatcherService.setIntervalSeconds(seconds: number): void`
|
||||
|
||||
- [ ] **Step 1: Add the i18n key first (needed by the test's translated toast message)**
|
||||
|
||||
In `src/app/i18n/translations.ts`, inside the `topbar:` block under `adminShell` (next to `notificationsEmpty: string;`):
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: string;
|
||||
notificationNewOrder: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, inside `adminShell.topbar` (next to `notificationsEmpty:`):
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'No new notifications',
|
||||
notificationNewOrder: 'New order #{{orderNumber}}',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, inside `adminShell.topbar`:
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'Нет новых уведомлений',
|
||||
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, inside `adminShell.topbar`:
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
||||
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
|
||||
```
|
||||
|
||||
(Match each file's existing `notificationsEmpty` value/indentation exactly — only add the new line after it.)
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' },
|
||||
payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 1000,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminOrderWatcherService', () => {
|
||||
let ordersByPoll: AdminOrder[][];
|
||||
let pollIndex: number;
|
||||
let notifications: UserNotificationService;
|
||||
let service: AdminOrderWatcherService;
|
||||
|
||||
function fakeGateway() {
|
||||
return {
|
||||
loadOrders: () => {
|
||||
const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1];
|
||||
const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 };
|
||||
return of(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pollIndex = 0;
|
||||
ordersByPoll = [
|
||||
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
|
||||
];
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
||||
],
|
||||
});
|
||||
|
||||
notifications = TestBed.inject(UserNotificationService);
|
||||
service = TestBed.inject(AdminOrderWatcherService);
|
||||
});
|
||||
|
||||
it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
expect(notifications.notifications().length).toBe(0);
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map(o => o.id)).toEqual(['o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(notifications.notifications().length).toBe(1);
|
||||
expect(notifications.notifications()[0].message).toContain('1003');
|
||||
expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']);
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
}));
|
||||
|
||||
it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
|
||||
service.markAllSeen();
|
||||
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map(o => o.id)).toEqual(['o3', 'o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
||||
service.setIntervalSeconds(30);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
service.setIntervalSeconds(0);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
service.setIntervalSeconds(-5);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: `LanguageService` defaults to `'ru'` (see `language.service.ts:23`), which is why the expected route in the second test starts with `'ru'`.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
|
||||
Expected: FAIL — `admin-order-watcher.service.ts` does not exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
Create `src/app/features/admin/shell/services/admin-order-watcher.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
|
||||
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
||||
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 15000;
|
||||
const MIN_POLL_INTERVAL_MS = 1000;
|
||||
const RECENT_ORDERS_LIMIT = 20;
|
||||
const TOAST_DURATION_MS = 4000;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminOrderWatcherService {
|
||||
private readonly gateway = inject(AdminOrdersLocalGateway);
|
||||
private readonly storage = inject(LocalStorageService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly i18n = inject(TranslateService);
|
||||
|
||||
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
||||
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
||||
|
||||
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
|
||||
|
||||
readonly unreadCount = computed(() => {
|
||||
const orders = this.recentOrdersSignal();
|
||||
if (orders.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const ackId = this.lastAcknowledgedOrderIdSignal();
|
||||
if (ackId === null) {
|
||||
return orders.length;
|
||||
}
|
||||
const idx = orders.findIndex(order => order.id === ackId);
|
||||
return idx === -1 ? orders.length : idx;
|
||||
});
|
||||
|
||||
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
|
||||
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
|
||||
|
||||
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
|
||||
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||
private started = false;
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.poll();
|
||||
this.scheduleNext();
|
||||
}
|
||||
|
||||
setIntervalSeconds(seconds: number): void {
|
||||
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
||||
return;
|
||||
}
|
||||
const ms = Math.round(seconds * 1000);
|
||||
this.intervalMsSignal.set(ms);
|
||||
this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
|
||||
if (this.started) {
|
||||
this.scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
markAllSeen(): void {
|
||||
const newestId = this.recentOrdersSignal()[0]?.id ?? null;
|
||||
this.lastAcknowledgedOrderIdSignal.set(newestId);
|
||||
if (newestId) {
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(): void {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
this.timerId = setInterval(() => this.poll(), this.intervalMsSignal());
|
||||
}
|
||||
|
||||
private poll(): void {
|
||||
this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({
|
||||
next: result => this.handleOrders(result.items),
|
||||
error: err => console.error('Error polling for new orders:', err),
|
||||
});
|
||||
}
|
||||
|
||||
private handleOrders(items: AdminOrder[]): void {
|
||||
this.recentOrdersSignal.set(items);
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstPoll = this.lastNotifiedOrderId === null;
|
||||
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
|
||||
const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex));
|
||||
|
||||
this.lastNotifiedOrderId = items[0].id;
|
||||
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
||||
|
||||
if (isFirstPoll) {
|
||||
// Nothing existed to compare against yet - treat current orders as already
|
||||
// acknowledged so a fresh admin session doesn't see the whole history as unread.
|
||||
if (this.lastAcknowledgedOrderIdSignal() === null) {
|
||||
this.lastAcknowledgedOrderIdSignal.set(items[0].id);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = newOrders.length - 1; i >= 0; i--) {
|
||||
const order = newOrders[i];
|
||||
this.notifications.show(
|
||||
this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }),
|
||||
'info',
|
||||
TOAST_DURATION_MS,
|
||||
[this.languageService.currentLanguage(), 'backoffice', 'orders', order.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readStoredIntervalMs(): number {
|
||||
const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY));
|
||||
return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
|
||||
Expected: PASS (4 specs)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/shell/services/admin-order-watcher.service.ts src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||||
git commit -m "feat: AdminOrderWatcherService polls for new orders and toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire the watcher into the admin topbar bell
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.ts`
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.html`
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.scss`
|
||||
- Test: `src/app/features/admin/shell/admin-layout.component.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrderWatcherService.{recentOrders, unreadCount, start, markAllSeen}` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/shell/admin-layout.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminLayoutComponent } from './admin-layout.component';
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
import { AdminOrder } from '../orders/models/admin-order.model';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
|
||||
payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 500,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt: '2026-08-15T10:00:00.000Z',
|
||||
updatedAt: '2026-08-15T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminLayoutComponent notifications bell', () => {
|
||||
let watcherStub: {
|
||||
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
||||
unreadCount: ReturnType<typeof signal<number>>;
|
||||
start: jasmine.Spy;
|
||||
markAllSeen: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||
unreadCount: signal(1),
|
||||
start: jasmine.createSpy('start'),
|
||||
markAllSeen: jasmine.createSpy('markAllSeen'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminLayoutComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the watcher once on construction', () => {
|
||||
TestBed.createComponent(AdminLayoutComponent);
|
||||
expect(watcherStub.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes unreadCount and recentOrders from the watcher', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.unreadCount()).toBe(1);
|
||||
expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
|
||||
});
|
||||
|
||||
it('marks orders seen when the notifications panel opens', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(true);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(false);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
|
||||
Expected: FAIL — `AdminOrderWatcherService` not referenced by the component yet, `unreadCount`/`recentOrders` don't exist on `AdminLayoutComponent`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.ts`, add the import and field (place near the other service injections):
|
||||
|
||||
```typescript
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
```
|
||||
|
||||
```typescript
|
||||
private readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||
|
||||
readonly unreadCount = this.orderWatcher.unreadCount;
|
||||
readonly recentOrders = this.orderWatcher.recentOrders;
|
||||
```
|
||||
|
||||
In the constructor, after `this.readRouteData();`, add:
|
||||
|
||||
```typescript
|
||||
this.orderWatcher.start();
|
||||
```
|
||||
|
||||
Replace the `toggleNotifications` method:
|
||||
|
||||
```typescript
|
||||
toggleNotifications(): void {
|
||||
this.notificationsOpen.update(open => !open);
|
||||
if (this.notificationsOpen()) {
|
||||
this.orderWatcher.markAllSeen();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a navigation helper next to `adminLinkFor`:
|
||||
|
||||
```typescript
|
||||
goToOrder(orderId: string): void {
|
||||
void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
|
||||
this.notificationsOpen.set(false);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.html`, replace the notifications block (lines 141-157):
|
||||
|
||||
```html
|
||||
<div class="admin-layout__notifications">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__icon-button"
|
||||
aria-haspopup="true"
|
||||
[attr.aria-expanded]="notificationsOpen()"
|
||||
[attr.aria-label]="'adminShell.topbar.notifications' | translate"
|
||||
(click)="toggleNotifications()"
|
||||
>
|
||||
<app-icon name="bell" [size]="18" />
|
||||
@if (unreadCount() > 0) {
|
||||
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (notificationsOpen()) {
|
||||
<div class="admin-layout__notifications-panel" role="menu">
|
||||
@if (recentOrders().length === 0) {
|
||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
||||
} @else {
|
||||
@for (order of recentOrders(); track order.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__notification-item"
|
||||
role="menuitem"
|
||||
(click)="goToOrder(order.id)"
|
||||
>
|
||||
<span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
|
||||
<span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
|
||||
<span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.scss`, add (near other `.admin-layout__notifications*` rules if any exist, otherwise at the end):
|
||||
|
||||
```scss
|
||||
.admin-layout__notifications {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-layout__notifications-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-layout__notification-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.admin-layout__notification-item:hover {
|
||||
background: var(--bg-secondary, #f4f6f5);
|
||||
}
|
||||
|
||||
.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); }
|
||||
.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
|
||||
.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
|
||||
Expected: PASS (3 specs)
|
||||
|
||||
- [ ] **Step 5: Run full build to catch template errors**
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds, no template compile errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/shell/admin-layout.component.ts src/app/features/admin/shell/admin-layout.component.html src/app/features/admin/shell/admin-layout.component.scss src/app/features/admin/shell/admin-layout.component.spec.ts
|
||||
git commit -m "feat: wire order watcher into admin topbar bell (badge + panel)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Editable poll interval in admin settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.ts`
|
||||
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.html`
|
||||
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts`
|
||||
- Test: `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrderWatcherService.{intervalMs, setIntervalSeconds}` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Add i18n keys**
|
||||
|
||||
In `src/app/i18n/translations.ts`, inside `adminSettings:` (after `currencyRatesSaved: string;`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: string;
|
||||
notificationIntervalExplain: string;
|
||||
notificationIntervalSave: string;
|
||||
notificationIntervalSaved: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Rates saved',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'New-order check interval (seconds)',
|
||||
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
|
||||
notificationIntervalSave: 'Save interval',
|
||||
notificationIntervalSaved: 'Interval saved',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Курсы сохранены',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'Интервал проверки новых заказов (сек)',
|
||||
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
|
||||
notificationIntervalSave: 'Сохранить интервал',
|
||||
notificationIntervalSaved: 'Интервал сохранён',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
|
||||
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
|
||||
notificationIntervalSave: 'Պահպանել ինտերվալը',
|
||||
notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminSettingsPageComponent } from './admin-settings-page.component';
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
|
||||
describe('AdminSettingsPageComponent notification interval', () => {
|
||||
let watcherStub: {
|
||||
intervalMs: ReturnType<typeof signal<number>>;
|
||||
setIntervalSeconds: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
intervalMs: signal(15000),
|
||||
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminSettingsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes the draft from the current interval in seconds', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15);
|
||||
});
|
||||
|
||||
it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.notificationIntervalSecondsDraft.set(30);
|
||||
component.saveNotificationInterval();
|
||||
|
||||
expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
|
||||
Expected: FAIL — `notificationIntervalSecondsDraft`/`saveNotificationInterval` don't exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
In `src/app/features/admin/settings/pages/admin-settings-page.component.ts`, add the import:
|
||||
|
||||
```typescript
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
```
|
||||
|
||||
Add the field and methods to the class (alongside the currency-rates fields):
|
||||
|
||||
```typescript
|
||||
readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||
readonly showNotificationIntervalSaved = signal(false);
|
||||
|
||||
saveNotificationInterval(): void {
|
||||
this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
|
||||
this.showNotificationIntervalSaved.set(true);
|
||||
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/app/features/admin/settings/pages/admin-settings-page.component.html`, add a new `.settings-card` block after the currency-rates one (before the closing `</section>`):
|
||||
|
||||
```html
|
||||
<div class="settings-card">
|
||||
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
|
||||
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
|
||||
<div class="rate-row">
|
||||
<input
|
||||
class="rate-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
[ngModel]="notificationIntervalSecondsDraft()"
|
||||
(ngModelChange)="notificationIntervalSecondsDraft.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-actions">
|
||||
<button type="button" class="save-button" (click)="saveNotificationInterval()">{{ 'adminSettings.notificationIntervalSave' | translate }}</button>
|
||||
<span class="saved-message" *ngIf="showNotificationIntervalSaved()">{{ 'adminSettings.notificationIntervalSaved' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
|
||||
Expected: PASS (2 specs)
|
||||
|
||||
- [ ] **Step 6: Full verification**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no errors.
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds.
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts' --include='**/admin-layout.component.spec.ts' --include='**/admin-settings-page.component.spec.ts' --include='**/user-notification.service.spec.ts'`
|
||||
Expected: all specs PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/settings/pages/admin-settings-page.component.ts src/app/features/admin/settings/pages/admin-settings-page.component.html src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||||
git commit -m "feat: editable new-order poll interval in admin settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Verification (after all tasks)
|
||||
|
||||
1. `npm run barry -- kb search --source cq --query "admin order notifications"` if KB sharing is enabled (skip if local-only — see project `CLAUDE.md`).
|
||||
2. Start the dev server, log into `/backoffice`, leave the tab open.
|
||||
3. In another tab (or via `admin-orders-local.gateway.ts`'s seed data timing), wait for the poll interval — confirm no toast fires on first load.
|
||||
4. Trigger a new order (checkout flow in `cart.component.ts`, or temporarily lower `SEED_COUNT`/seed timing in `admin-orders-local.gateway.ts` to simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows `1`, clicking either navigates to `/backoffice/orders/:id`.
|
||||
5. Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel.
|
||||
6. Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.
|
||||
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# Frontend Default Bootstrap Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit `published: boolean` field on the `/bootstrap` response, not from HTTP status.
|
||||
|
||||
**Architecture:** Add `published` to `BootstrapConfig`. Add one new frontend-only constant `DEFAULT_BOOTSTRAP: BootstrapConfig`, composed from existing `DEFAULT_HEADER_CONFIG` / `DEFAULT_MARKETPLACE_FEATURES_CONFIG` / `DEFAULT_PLATFORM_MODULES_CONFIG` plus a hardcoded generic shell for the sections with no existing default (`tenant`, `branding`, `theme`, `company`, `featureFlags`, `apiEndpoints`, `localization`, `seo`, `permissions`, `navigation`, `footer`, `pages`, `staticPages`). `ConfigService.loadBootstrap()` swaps its cached snapshot to `DEFAULT_BOOTSTRAP` whenever the fetched response has `published === false`. No provider changes.
|
||||
|
||||
**Tech Stack:** Angular 22, RxJS, Jasmine/Karma (existing `.spec.ts` pattern in this repo).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `published` missing/undefined on a response must be treated as `true` (backward compatible — matches the existing pattern for `modules`/ADR-011).
|
||||
- Fallback is a whole-object swap — no field-level merging with the real response.
|
||||
- Fallback triggers only on the explicit `published: false` signal, never on HTTP failure (existing `catchError` behavior in `ConfigService` is untouched).
|
||||
- `DEFAULT_BOOTSTRAP.featureFlags` and `.features` must have every flag `true`.
|
||||
- Reuse `DEFAULT_HEADER_CONFIG`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG`, `DEFAULT_PLATFORM_MODULES_CONFIG` as-is — do not redefine their values inline.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `published` to the `BootstrapConfig` contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/shared/models/config/bootstrap-config.model.ts`
|
||||
- Modify: `src/assets/mock/bootstrap/bootstrap.json` (add `"published": true` so the existing mock keeps behaving as "already live")
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `BootstrapConfig.published: boolean` — consumed by Task 3 (`ConfigService`).
|
||||
|
||||
- [ ] **Step 1: Add the field to the interface**
|
||||
|
||||
In `src/app/shared/models/config/bootstrap-config.model.ts`, add `published` right after `generatedAt`:
|
||||
|
||||
```ts
|
||||
export interface BootstrapConfig {
|
||||
schemaVersion: string;
|
||||
generatedAt: string;
|
||||
published: boolean;
|
||||
tenant: TenantConfig;
|
||||
branding: BrandingConfig;
|
||||
theme: ThemeConfig;
|
||||
company: CompanyConfig;
|
||||
featureFlags: FeatureFlagsConfig;
|
||||
features?: MarketplaceFeaturesConfig;
|
||||
apiEndpoints: ApiEndpointsConfig;
|
||||
localization: LocalizationConfig;
|
||||
seo: SeoConfig;
|
||||
permissions: PermissionsConfig;
|
||||
header?: HeaderConfig;
|
||||
catalog?: CatalogConfig;
|
||||
layout?: PlatformLayoutConfig;
|
||||
navigation: NavigationConfig;
|
||||
footer?: FooterConfig;
|
||||
productPage?: ProductPageConfig;
|
||||
userExperience?: UserExperienceConfig;
|
||||
pages: PageConfig[];
|
||||
staticPages?: StaticPagesConfig;
|
||||
widgetRegistry?: WidgetRegistryConfig;
|
||||
modules?: PlatformModulesConfig;
|
||||
seller?: SellerConfig;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the mock fixture**
|
||||
|
||||
In `src/assets/mock/bootstrap/bootstrap.json`, add `"published": true,` as the line right after `"generatedAt": "2026-07-03T00:00:00Z",` (line 3).
|
||||
|
||||
- [ ] **Step 3: Compile check**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no new errors referencing `bootstrap-config.model.ts` or `bootstrap.json` (the mock file isn't type-checked, but any TS consumer that builds a `BootstrapConfig` object literal without `published` will now fail — confirms the field is wired through).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json
|
||||
git commit -m "feat: add published field to BootstrapConfig contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add the `DEFAULT_BOOTSTRAP` constant
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/shared/models/config/default-bootstrap.const.ts`
|
||||
- Modify: `src/app/shared/models/config/index.ts` (export the new file)
|
||||
- Test: `src/app/shared/models/config/default-bootstrap.const.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `BootstrapConfig` (Task 1), `DEFAULT_HEADER_CONFIG` from `./header-config.model`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG` from `./features-config.model`, `DEFAULT_PLATFORM_MODULES_CONFIG` from `./platform-modules.model`.
|
||||
- Produces: `DEFAULT_BOOTSTRAP: BootstrapConfig` — consumed by Task 3 (`ConfigService`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/shared/models/config/default-bootstrap.const.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
|
||||
|
||||
describe('DEFAULT_BOOTSTRAP', () => {
|
||||
it('is marked unpublished', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
|
||||
});
|
||||
|
||||
it('has every feature flag turned on', () => {
|
||||
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
|
||||
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('has at least one page with a hero section', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
|
||||
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
|
||||
expect(heroSection).toBeDefined();
|
||||
});
|
||||
|
||||
it('has a generic brand name, not a real tenant name', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
|
||||
Expected: FAIL — `Cannot find module './default-bootstrap.const'`
|
||||
|
||||
- [ ] **Step 3: Write the constant**
|
||||
|
||||
Create `src/app/shared/models/config/default-bootstrap.const.ts`:
|
||||
|
||||
```ts
|
||||
import { BootstrapConfig } from './bootstrap-config.model';
|
||||
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
|
||||
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
|
||||
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
|
||||
|
||||
/**
|
||||
* Whole-object fallback rendered whenever the backend reports
|
||||
* `published: false` for the resolved marketplace (no published revision
|
||||
* yet). Every feature flag is on so it doubles as a full-surface product
|
||||
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
|
||||
*/
|
||||
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
|
||||
schemaVersion: '1.0.0',
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
published: false,
|
||||
|
||||
tenant: {
|
||||
id: 'tenant-default-unpublished',
|
||||
slug: 'default',
|
||||
code: 'DEFAULT',
|
||||
host: 'default.local',
|
||||
name: 'Marketplace',
|
||||
websiteBaseUrl: 'https://marketplace.local',
|
||||
builderBaseUrl: 'https://builder.marketplace.local',
|
||||
backofficeBaseUrl: 'https://backoffice.marketplace.local',
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en'],
|
||||
defaultCurrency: 'USD',
|
||||
supportedCurrencies: ['USD'],
|
||||
timezone: 'UTC',
|
||||
},
|
||||
|
||||
branding: {
|
||||
brandName: 'Marketplace',
|
||||
legalName: 'Marketplace',
|
||||
slogan: 'Your store, coming soon',
|
||||
logoUrl: '/icons/icon-192x192.png',
|
||||
logoCompactUrl: '/icons/icon-192x192.png',
|
||||
faviconUrl: '/favicon.ico',
|
||||
appIconUrl: '/icons/icon-192x192.png',
|
||||
supportEmail: 'support@marketplace.local',
|
||||
},
|
||||
|
||||
theme: {
|
||||
themeId: 'default-light',
|
||||
mode: 'light',
|
||||
palette: {
|
||||
primary: '#497671',
|
||||
secondary: '#a1b4b5',
|
||||
accent: '#a7ceca',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
textPrimary: '#1e3c38',
|
||||
textSecondary: '#667a77',
|
||||
backgroundPrimary: '#ffffff',
|
||||
backgroundSecondary: '#f5f5f5',
|
||||
border: '#d3dad9',
|
||||
},
|
||||
typography: {
|
||||
primaryFontFamily: 'DM Sans, sans-serif',
|
||||
headingFontFamily: 'DM Sans, sans-serif',
|
||||
baseFontSize: 16,
|
||||
},
|
||||
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
|
||||
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
|
||||
shadows: {
|
||||
sm: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
md: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
lg: '0 12px 32px rgba(73,118,113,0.2)',
|
||||
},
|
||||
iconSet: 'default',
|
||||
},
|
||||
|
||||
company: {
|
||||
companyName: 'Marketplace',
|
||||
address: { country: '', city: '' },
|
||||
contacts: { email: 'support@marketplace.local' },
|
||||
},
|
||||
|
||||
featureFlags: {
|
||||
wishlist: true,
|
||||
compare: true,
|
||||
reviews: true,
|
||||
questions: true,
|
||||
comments: true,
|
||||
recommendations: true,
|
||||
blog: true,
|
||||
chat: true,
|
||||
analytics: true,
|
||||
notifications: true,
|
||||
coupons: true,
|
||||
loyalty: true,
|
||||
giftCards: true,
|
||||
invoices: true,
|
||||
},
|
||||
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
|
||||
|
||||
apiEndpoints: {
|
||||
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
|
||||
website: {},
|
||||
builder: {},
|
||||
backoffice: {},
|
||||
},
|
||||
|
||||
localization: {
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en'],
|
||||
currencyByLocale: { en: 'USD' },
|
||||
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
|
||||
},
|
||||
|
||||
seo: {
|
||||
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
|
||||
byPageKey: {
|
||||
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
|
||||
},
|
||||
},
|
||||
|
||||
permissions: { definitions: [], roles: [] },
|
||||
|
||||
header: DEFAULT_HEADER_CONFIG,
|
||||
|
||||
navigation: {
|
||||
header: [
|
||||
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
|
||||
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
|
||||
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
|
||||
],
|
||||
footer: [
|
||||
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
|
||||
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
|
||||
],
|
||||
},
|
||||
|
||||
footer: {
|
||||
paymentIcons: [],
|
||||
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
|
||||
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
|
||||
},
|
||||
|
||||
staticPages: {
|
||||
'about-us': {
|
||||
route: '/about-us',
|
||||
title: { en: 'About Us' },
|
||||
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
|
||||
},
|
||||
'privacy-policy': {
|
||||
route: '/privacy-policy',
|
||||
title: { en: 'Privacy Policy' },
|
||||
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
|
||||
},
|
||||
'terms-of-service': {
|
||||
route: '/terms-of-service',
|
||||
title: { en: 'Terms of Service' },
|
||||
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
|
||||
},
|
||||
},
|
||||
|
||||
pages: [
|
||||
{
|
||||
id: 'page-home',
|
||||
key: 'home',
|
||||
title: 'Home',
|
||||
route: { path: '/', exact: true },
|
||||
layout: { type: 'default' },
|
||||
seoKey: 'home',
|
||||
visible: true,
|
||||
sections: [
|
||||
{
|
||||
id: 'section-hero',
|
||||
type: 'hero',
|
||||
order: 1,
|
||||
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-hero-main',
|
||||
type: 'hero',
|
||||
version: '1.0.0',
|
||||
order: 1,
|
||||
padding: '0.5rem 0',
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
props: {
|
||||
title: { en: 'Welcome to Marketplace' },
|
||||
subtitle: { en: 'This storefront has not been published yet' },
|
||||
ctaLabel: { en: 'Learn more' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'section-categories',
|
||||
type: 'categories',
|
||||
order: 2,
|
||||
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-categories-root',
|
||||
type: 'categories',
|
||||
version: '1.0.0',
|
||||
order: 1,
|
||||
padding: '0.25rem 0',
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Export it from the barrel file**
|
||||
|
||||
In `src/app/shared/models/config/index.ts`, add one line (alphabetical position, after `catalog-config.model`):
|
||||
|
||||
```ts
|
||||
export * from './default-bootstrap.const';
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
|
||||
Expected: PASS (5 specs)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts
|
||||
git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Swap to `DEFAULT_BOOTSTRAP` in `ConfigService` when unpublished
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/core/config/config.service.ts`
|
||||
- Test: `src/app/core/config/config.service.spec.ts` (new file — none exists today)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEFAULT_BOOTSTRAP` (Task 2), `BootstrapConfig.published` (Task 1), existing `CONFIG_PROVIDER` token / `ConfigProvider.loadBootstrap()`.
|
||||
- Produces: no new public method — `loadBootstrap()` and `getBootstrapSnapshot()` keep their existing signatures; behavior changes only in which object ends up cached.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `src/app/core/config/config.service.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { ConfigService } from './config.service';
|
||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||
import { ConfigProvider } from './config-provider.interface';
|
||||
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||
|
||||
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
|
||||
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
|
||||
}
|
||||
|
||||
describe('ConfigService', () => {
|
||||
let provider: jasmine.SpyObj<ConfigProvider>;
|
||||
|
||||
function setup(response: BootstrapConfig): ConfigService {
|
||||
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
|
||||
provider.loadBootstrap.and.returnValue(of(response));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
|
||||
});
|
||||
return TestBed.inject(ConfigService);
|
||||
}
|
||||
|
||||
it('caches the real response when published is true', done => {
|
||||
const real = makeRealBootstrap();
|
||||
const service = setup(real);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result.tenant.name).toBe('Acme');
|
||||
expect(service.getBootstrapSnapshot()).toEqual(real);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
|
||||
const draft = makeRealBootstrap({ published: false });
|
||||
const service = setup(draft);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result).toEqual(DEFAULT_BOOTSTRAP);
|
||||
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a missing published field as published (backward compatible)', done => {
|
||||
const legacy = makeRealBootstrap();
|
||||
delete (legacy as Partial<BootstrapConfig>).published;
|
||||
const service = setup(legacy);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result.tenant.name).toBe('Acme');
|
||||
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
|
||||
Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — `result` currently equals `draft` (unpublished, unswapped), not `DEFAULT_BOOTSTRAP`.
|
||||
|
||||
- [ ] **Step 3: Implement the swap**
|
||||
|
||||
Replace the body of `src/app/core/config/config.service.ts` with:
|
||||
|
||||
```ts
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
|
||||
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConfigService {
|
||||
private readonly provider = inject(CONFIG_PROVIDER);
|
||||
|
||||
private bootstrapSnapshot: BootstrapConfig | null = null;
|
||||
private bootstrap$?: Observable<BootstrapConfig>;
|
||||
private readonly revisionState = signal(0);
|
||||
|
||||
readonly bootstrapRevision = this.revisionState.asReadonly();
|
||||
|
||||
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
|
||||
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
|
||||
return this.bootstrap$;
|
||||
}
|
||||
|
||||
if (!this.bootstrap$ || forceRefresh) {
|
||||
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
||||
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
|
||||
tap(config => {
|
||||
this.bootstrapSnapshot = config;
|
||||
this.revisionState.update(value => value + 1);
|
||||
}),
|
||||
shareReplay(1),
|
||||
catchError(error => {
|
||||
this.bootstrap$ = undefined;
|
||||
this.bootstrapSnapshot = null;
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.bootstrap$;
|
||||
}
|
||||
|
||||
getBootstrapSnapshot(): BootstrapConfig | null {
|
||||
return this.bootstrapSnapshot;
|
||||
}
|
||||
|
||||
applyBootstrapOverride(next: BootstrapConfig): void {
|
||||
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
|
||||
this.bootstrapSnapshot = cloned;
|
||||
this.bootstrap$ = of(cloned);
|
||||
this.revisionState.update(value => value + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The only change from the current file: the `map` operator inserted before `tap`, and the `DEFAULT_BOOTSTRAP` import. `config.published === false` (strict) rather than `!config.published` is deliberate — it makes `undefined`/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
|
||||
Expected: PASS (3 specs)
|
||||
|
||||
- [ ] **Step 5: Run the full unit suite to check for regressions**
|
||||
|
||||
Run: `ng test --watch=false`
|
||||
Expected: PASS, no new failures (existing consumers of `ConfigService` only rely on `loadBootstrap()`/`getBootstrapSnapshot()`, unchanged signatures).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts
|
||||
git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: E2E smoke test for the unpublished placeholder
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/smoke.spec.ts` (existing Playwright smoke suite)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Playwright route interception (`page.route`), `DEFAULT_BOOTSTRAP.branding.brandName` (Task 2) as the assertion target.
|
||||
|
||||
- [ ] **Step 1: Read the existing smoke spec to match its conventions**
|
||||
|
||||
Run: `cat e2e/smoke.spec.ts` (or open the file) — confirm the existing pattern for intercepting `/bootstrap` if one exists, and the base URL fixture used by other specs in this file.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Add to `e2e/smoke.spec.ts`:
|
||||
|
||||
```ts
|
||||
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
|
||||
await page.route('**/bootstrap', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it to verify it fails**
|
||||
|
||||
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
|
||||
Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 1–3 aren't wired in yet if this task runs standalone. If Tasks 1–3 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code.
|
||||
|
||||
- [ ] **Step 4: Confirm it passes against the real implementation**
|
||||
|
||||
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
|
||||
Expected: PASS, once Tasks 1–3 are committed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/smoke.spec.ts
|
||||
git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** §1 (backend `published` field) → Task 1. §2 (whole-object swap, `DEFAULT_BOOTSTRAP` composed from existing `DEFAULT_*` constants) → Task 2. §2 (`ConfigService` trigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leaving `catchError` untouched. §4 (testing) → Tasks 2–4 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile against `BootstrapConfig`).
|
||||
- **Backend-side resolution logic** (how the backend decides `published` from `MarketplaceRevision.status`) is explicitly out of scope per the spec — not a frontend-repo task.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Admin product view count column — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #1 (site traffic counter)
|
||||
|
||||
## Problem
|
||||
|
||||
User reported "site traffic isn't visible, counter shows low." Investigation found two separate things already exist and are working as intended, neither of which is the actual gap:
|
||||
|
||||
- Admin Analytics → Traffic tab already shows an honest `"Unknown - available after backend"` badge (`admin-analytics-page.component.html:231`) — no fake data, correctly reflects that no traffic-tracking pipeline exists at all (`BACKEND-API-REFERENCE.md` §10 step 10).
|
||||
- The storefront `Item.visits` field is wired end-to-end from the live backend (`api.service.ts:438`) but is never rendered anywhere in the UI, and the backend mock always seeds it `0`.
|
||||
|
||||
User confirmed (via clarifying question) the actual complaint is: **no per-product view count visible in Admin Products.**
|
||||
|
||||
Further investigation found Admin Products runs on a fully separate mock domain (`AdminProduct` model, `admin-products-local.gateway.ts`, seeded from `list.json`) that has no relationship to the storefront's live `Item.visits` pipeline at all. So a "Views" column here cannot show real per-product traffic today — there is no data source for it in the admin domain. This mirrors the currency/FX and order-notification gaps already documented this session: build the honest client-side piece, document the backend gap explicitly, never fabricate numbers.
|
||||
|
||||
## Design
|
||||
|
||||
**Model:** add `visits: number` to `AdminProduct` (`src/app/features/admin/products/models/admin-product.model.ts`), alongside the other stat-like fields (`priority`, `quantity`).
|
||||
|
||||
**Mock gateway:** `admin-products-local.gateway.ts` defaults `visits: 0` when building the in-memory seed from `list.json` — no fabricated numbers, matches the field's actual state (nothing increments it yet).
|
||||
|
||||
**List column:** `ALL_PRODUCT_COLUMNS` (`admin-products.facade.ts:39`) gains `'visits'`. Rendered in `admin-products-list.component.html` table view only (grid view is out of scope per user's placement choice), following the exact existing `isColumnVisible('stock')`/`isColumnVisible('price')` pattern — toggleable via the same column-picker UI, persisted the same way (`LocalStorageService`, `COLUMNS_KEY`).
|
||||
|
||||
**i18n:** one new key, `adminProducts.views` (label for the column header), added to `en.ts`/`ru.ts`/`hy.ts`/`translations.ts`.
|
||||
|
||||
## Backend doc update
|
||||
|
||||
New `BACKEND-API-REFERENCE.md` §12.x ask (numbered after the existing 12.8, following the established "Gap / Ask" format): the admin Products domain has no view-count source. Two options to raise:
|
||||
1. Once admin Products gets a real backend (§10 step 4), include a view/visit count per product in the response.
|
||||
2. Alternatively, bridge to the storefront's already-live `Item.visits` (§6, `/items/{id}`) by product id — smaller change if a unified product identity exists between the storefront and admin domains.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Storefront customer-facing "N people viewed this" display — not requested, deferred (was offered as a placement option, not chosen).
|
||||
- Product edit/detail page display — not requested (list column only, per user's placement choice).
|
||||
- Any client-side view tracking/incrementing — explicitly rejected in favor of the honest display-only approach; a client-only counter would only reflect the admin's own browser, not real shoppers, same trap already avoided for currency rates.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Admin purchase notifications — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #7 (marked ВАЖНО — important)
|
||||
|
||||
## Problem
|
||||
|
||||
Admin has no signal when a purchase happens on the marketplace. Orders only surface if
|
||||
someone manually opens the Orders list and refreshes. Backend exposes no WebSocket/SSE
|
||||
(confirmed in `BACKEND-API-REFERENCE.md:20` — every "live" feature today, e.g. payment
|
||||
status, is plain polling), so this has to be poll-based like the rest of the app.
|
||||
|
||||
## Architecture
|
||||
|
||||
**`AdminOrderWatcherService`** (new, `providedIn: root`, admin-scoped)
|
||||
|
||||
- Polls `AdminOrdersLocalGateway.loadOrders()` (sorted `createdAt` desc, already the
|
||||
default sort) on an interval.
|
||||
- Diffs the newest order's `id`/`createdAt` against the last-seen value, kept in memory
|
||||
and persisted via `LocalStorageService` (survives page reload, same pattern as
|
||||
`AdminPreferencesService`).
|
||||
- On finding order(s) newer than last-seen: fires one toast per new order and
|
||||
increments an `unreadCount` signal.
|
||||
- Started once at the admin shell root, so it keeps polling regardless of which admin
|
||||
page is open.
|
||||
|
||||
**Poll interval**
|
||||
|
||||
- Editable by admin, default 15s.
|
||||
- Setting lives in the same admin-settings page as currency rates
|
||||
(`admin-settings-page.component.ts`), persisted via `LocalStorageService`.
|
||||
|
||||
**Toast delivery**
|
||||
|
||||
- Reuses the existing `UserNotificationService` / `FloatingNotificationsComponent`
|
||||
(already global — `providedIn: root`, mounted once in `app.html`). No new toast UI.
|
||||
- `UserNotification` gains an optional `route: string[]` field.
|
||||
- `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set)
|
||||
then dismiss.
|
||||
|
||||
**Badge — reuses existing topbar bell**
|
||||
|
||||
`admin-layout.component.html:141-157` already has an unused bell icon +
|
||||
dropdown panel (currently hardcoded to always show "no notifications").
|
||||
Wire the watcher's data into it instead of adding a new indicator:
|
||||
|
||||
- `unreadCount` signal (from `AdminOrderWatcherService`) rendered as a badge
|
||||
on the bell icon (`admin-layout__icon-button`).
|
||||
- Opening the panel (`notificationsOpen()`, already wired to the bell click)
|
||||
lists the unread new orders instead of the static "notificationsEmpty"
|
||||
text.
|
||||
- Opening the panel marks all currently-known orders as seen → badge resets
|
||||
to 0 (same trigger `AdminLayoutComponent.toggleNotifications()` already
|
||||
has).
|
||||
|
||||
**Click behavior**
|
||||
|
||||
- Toast click → `/admin/orders/:id` (the new order's detail page).
|
||||
- Clicking an order row inside the bell panel → same, then closes the panel.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
AdminOrderWatcherService (interval timer)
|
||||
-> AdminOrdersLocalGateway.loadOrders()
|
||||
-> diff against last-seen order id/createdAt (LocalStorageService)
|
||||
-> new order(s) found?
|
||||
-> UserNotificationService.show(message, 'info', { route: ['/admin/orders', id] })
|
||||
-> unreadCount.update(n => n + 1)
|
||||
-> admin clicks toast/badge -> router navigate -> orders-list visit resets unreadCount
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Poll failures are silent/logged only (`console.error`), consistent with existing
|
||||
polling code (payment status polling in `cart.component.ts`). No toast spam on
|
||||
transient network errors — watcher just retries on the next interval.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Native OS push notifications (tab not focused) — user explicitly chose in-app
|
||||
toast/badge only, not browser Notification API.
|
||||
- Sound alerts — not selected.
|
||||
- Telegram/email alerts to staff — not selected, would need backend bot/mail
|
||||
integration.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Email/phone customer login (OTP) — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #4 (Telegram-only identification)
|
||||
|
||||
## Problem
|
||||
|
||||
Customer storefront login/checkout requires Telegram today (`src/app/services/auth.service.ts`, `TelegramSessionApiService`) — shoppers without Telegram have no way to identify themselves. User asked for email/phone as an alternative.
|
||||
|
||||
Backend has zero email/phone/OTP/password infrastructure — only Telegram session polling exists (`BACKEND-API-REFERENCE.md` §2a). Building real authentication client-side is not possible; this is fundamentally a backend feature. Per user's explicit choice, this round produces the design + backend spec only — no client UI/code, since there is no real backend to build a working feature against yet (mirrors the currency-FX and admin-notifications backend-dependency pattern already documented this session).
|
||||
|
||||
## Design
|
||||
|
||||
**Mechanism: OTP code (email or SMS)**, chosen over magic link (email-only, extra click) and password (heaviest backend lift — storage, hashing, reset flow). Passwordless matches the feel of the existing Telegram QR flow.
|
||||
|
||||
**A third, independent auth mechanism** — coexists with Telegram QR (§2a) and admin Ed25519 (§2b, still unimplemented) exactly the way those two already coexist. Does not replace or modify either.
|
||||
|
||||
### Proposed backend endpoints
|
||||
|
||||
```
|
||||
POST /auth/otp/request
|
||||
Body: { "identifier": "user@example.com" } // or E.164 phone: "+79991234567"
|
||||
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
|
||||
```
|
||||
|
||||
```
|
||||
POST /auth/otp/verify
|
||||
Body: { "requestId": "...", "code": "482913" }
|
||||
Response (on success): {
|
||||
"sessionId": "...",
|
||||
"userId": 8823771,
|
||||
"username": null,
|
||||
"displayName": "user@example.com",
|
||||
"active": true,
|
||||
"expires": "2026-08-15T11:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The success response is shaped identically to the existing `AuthSession` model (`src/app/models/auth.model.ts`) — `sessionId`, `userId`, `username`, `displayName`, `active`, `expires`. This is deliberate: every downstream consumer (auth guards, session signals, cart/checkout) already works against `AuthSession` regardless of which mechanism produced it, so wiring this in later requires no changes to guards or session state — only a new "request/verify" UI flow that ends by populating the same session shape Telegram QR already produces.
|
||||
|
||||
**Rate limiting / expiry (backend-enforced, not left implicit):**
|
||||
- Resend cooldown: 60s between `POST /auth/otp/request` calls for the same identifier.
|
||||
- Code expiry: 10 minutes from issuance.
|
||||
- `requestId` allows up to 5 verify attempts before it's invalidated — consumed on success, on the 5th wrong attempt, or on expiry, whichever comes first. (Revised from an earlier single-use-per-attempt draft: burning the whole request on one typo is bad UX — a shopper should be able to correct a mistyped digit without waiting out a fresh 60s cooldown.)
|
||||
|
||||
### Admin-configurable login methods
|
||||
|
||||
Admin can enable/disable each login method independently — Telegram QR, Email OTP, Phone OTP — via three checkboxes in Admin Settings, same section/pattern as the existing currency-rates and notification-interval settings (`admin-settings-page.component.ts`, `LocalStorageService`-persisted signal).
|
||||
|
||||
- Default: all three enabled — a settings change must never silently lock shoppers out.
|
||||
- A new `AuthMethodsService` (or an extension of the existing settings service) exposes `enabledMethods: Signal<('telegram' | 'email' | 'phone')[]>`. The storefront login screen reads it and only renders buttons for enabled methods; if exactly one is enabled, skip the method-picker screen entirely and go straight to it.
|
||||
- Purely a client-side UI gate — the backend OTP endpoints stay unconditionally available; disabling "Email OTP" in admin just hides the button, it doesn't need a corresponding backend flag. (Same category of client-only gate as the existing `adminAuthGuard`/permission checks — real enforcement, if ever needed, would be a separate backend concern.)
|
||||
|
||||
### Error handling
|
||||
|
||||
The codebase has an established error envelope (`BACKEND-API-REFERENCE.md` §5: `error.code`, `error.message`, `error.status`, `error.details`) explicitly flagged as "recommended for new endpoints, not wired anywhere yet." Since the OTP endpoints are new, this is the natural first real adopter — every response maps to a specific code, not just an HTTP status:
|
||||
|
||||
| `error.code` | HTTP status | UX |
|
||||
|---|---|---|
|
||||
| `VALIDATION_FAILED` | 422 | Inline field error under the identifier input, sourced from `error.details[0].message` — same pattern the client already uses for local validation errors (`cart.component.ts`'s email/phone inline errors), so a 422 slots into the existing inline-error UI without inventing a second display mechanism. |
|
||||
| `RATE_LIMITED` | 429 | "Too many attempts — try again in Ns," countdown derived from `error.details`/`Retry-After` if present, otherwise a flat 60s. Resend button stays disabled until the countdown ends. |
|
||||
| `CODE_EXPIRED` | 410 | "This code expired — request a new one." Auto-focuses/enables the resend action; does not silently re-send. |
|
||||
| `CODE_INVALID` | 401 | "Wrong code, try again" — stays on the code-entry screen (does not consume the whole flow; see the 5-attempt allowance above). Shows the remaining-attempts count once ≤2 remain. |
|
||||
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown/already invalidated (5 wrong attempts, expiry, or a stale reload) — "This login attempt is no longer valid, start again," returns to the identifier-entry step. |
|
||||
| Anything else / network error / 5xx | — | Generic fallback: "Something went wrong. Try again, or use a different login method" — the second half of that sentence is a real, populated action, not filler text: it surfaces whichever other methods are currently enabled per the admin toggle above (e.g. falls back to the Telegram QR button), not just a dead-end retry link. |
|
||||
|
||||
**Identifier validation:** email vs. phone format is auto-detected client-side. Extract the validation logic already written inline in `cart.component.ts` (`validateEmail`/`validatePhone`, currently only used for post-purchase contact capture) into a shared utility rather than duplicating it when the client UI is eventually built — the same email/phone shape-checking applies to both use cases.
|
||||
|
||||
### Future client UI (not built this round)
|
||||
|
||||
A "Login with email or phone" option next to the existing Telegram QR button: identifier entry → code entry → session established. Deferred until the backend endpoints above exist — no client code to write against a 404.
|
||||
|
||||
## Backend doc update
|
||||
|
||||
New `BACKEND-API-REFERENCE.md` §2c ("Email/phone OTP login — customer (NOT IMPLEMENTED)"), following the same Gap/Ask format as the existing §12.x entries, documenting the two endpoints, the response-shape compatibility requirement, and the rate-limit/expiry asks above.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Admin backoffice login — user confirmed this round is customer-storefront only (item 4 was split into two potential specs during brainstorming; admin auth is a separate future spec if wanted).
|
||||
- Magic link and password mechanisms — considered, OTP chosen.
|
||||
- Any client-side UI or session-handling code — explicitly deferred; nothing to build against a non-existent backend.
|
||||
- Account merging (e.g. a shopper who later links Telegram + email to the same identity) — not raised, not designed.
|
||||
137
docs/superpowers/specs/2026-08-15-platform-super-admin-design.md
Normal file
137
docs/superpowers/specs/2026-08-15-platform-super-admin-design.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Platform Super-Admin — Phase 1 Design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Audience:** Internal admin & risk team ("super puper user")
|
||||
|
||||
## Purpose
|
||||
|
||||
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
|
||||
platform, drill into one, and review its access list, audit log, admin edit history, and
|
||||
purchase history. Read-only in this phase.
|
||||
|
||||
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
|
||||
this store's admin" toggle) is explicitly **out of scope** for this phase — see
|
||||
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
|
||||
notify plumbing depends on the tenant-context switch this phase builds.
|
||||
|
||||
## Non-goals (Phase 1)
|
||||
|
||||
- No editing of any tenant's data.
|
||||
- No impersonation of a store's admin.
|
||||
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
|
||||
don't exist yet).
|
||||
- No real backend — this repo is frontend-only; the backend contract is specified here for
|
||||
whoever owns that service, not implemented here.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New top-level feature module: `src/app/features/platform-admin/`.
|
||||
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
|
||||
`/admin/**` — a project is not "logged into" the way a store admin is.
|
||||
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
|
||||
`core/admin-auth/admin-auth.guard.ts`).
|
||||
- `PlatformAuthService` — session/login state for the super-admin, backed by a
|
||||
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
|
||||
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
|
||||
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
|
||||
never committed, never present in a production build path.
|
||||
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
|
||||
interface, no caller changes needed.
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
interface PlatformProjectSummary {
|
||||
id: UUID;
|
||||
name: string;
|
||||
slug: string;
|
||||
host: string;
|
||||
status: 'active' | 'suspended';
|
||||
createdAt: number;
|
||||
adminCount: number;
|
||||
lastActivityAt: number | null;
|
||||
}
|
||||
|
||||
interface PlatformProjectAccessEntry {
|
||||
userId: UUID;
|
||||
displayName: string;
|
||||
telegramUsername: string;
|
||||
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
|
||||
}
|
||||
|
||||
type PlatformProjectHistoryEntry =
|
||||
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
|
||||
```
|
||||
|
||||
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
|
||||
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
|
||||
aggregation is a backend concern later.
|
||||
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
|
||||
`core/auth/models/permission.model.ts` — no new role system.
|
||||
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
|
||||
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
|
||||
aggregation from existing per-tenant sources (e.g. the pattern in
|
||||
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
|
||||
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
|
||||
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
|
||||
changed.
|
||||
|
||||
## Components / pages
|
||||
|
||||
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
|
||||
last activity. Search/filter by status.
|
||||
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
|
||||
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
|
||||
- **Audit Log** — `history` filtered to `kind: 'access'`.
|
||||
- **Edit History** — `history` filtered to `kind: 'edit'`.
|
||||
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
|
||||
- All read-only in this phase.
|
||||
|
||||
## Security
|
||||
|
||||
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
|
||||
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
|
||||
enforcement must happen server-side once the backend endpoint exists. This is called out
|
||||
explicitly so it's never mistaken for the source of truth.
|
||||
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
|
||||
git-ignored local file; production auth goes through the real backend endpoint below.
|
||||
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
|
||||
sessions — higher-privilege session, smaller blast radius if a session is left open).
|
||||
- Every super-admin action (including read-only views) is itself audit-logged.
|
||||
- After implementation, run `/security-audit` on this feature specifically before it ships.
|
||||
|
||||
### Backend contract (for whoever owns that service — not implemented in this repo)
|
||||
|
||||
Add to `BACKEND-API-REFERENCE.md`:
|
||||
|
||||
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
|
||||
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
|
||||
artifact.
|
||||
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
|
||||
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
|
||||
that tenant, paginated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
|
||||
mapping logic.
|
||||
- No E2E in this phase — no real backend to exercise end-to-end yet.
|
||||
|
||||
## Phase 2 (out of scope here)
|
||||
|
||||
A separate spec/plan cycle, once Phase 1 ships:
|
||||
|
||||
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
|
||||
module (products, orders, categories, settings, etc.), reusing those modules under a
|
||||
tenant-context switch.
|
||||
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
|
||||
unchecked**. Uses the existing in-app notification pattern (the one behind
|
||||
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
|
||||
in their notification feed. Unchecked-by-default matters: some super-admin edits are
|
||||
discreet technical fixes where alerting the store admin would be noise or a reputational
|
||||
concern, not every edit should ping them.
|
||||
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
|
||||
establishes, which is why it's sequenced after.
|
||||
201
docs/superpowers/specs/2026-08-21-fork-harvest-design.md
Normal file
201
docs/superpowers/specs/2026-08-21-fork-harvest-design.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Fork Harvest — Design / Working Description
|
||||
|
||||
**Branch:** `improvements/fork-harvest` (cut from `B2B` @ `92f1c88`)
|
||||
**Date:** 2026-08-21
|
||||
**Input:** [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md)
|
||||
**Companion:** [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Take **only the improvements** from the `hub.numus.cc/numus/marketplaces` archive. Nothing else. No architecture adoption, no code copying, no rewrite, no framework regression.
|
||||
|
||||
The archive is a competing platform monorepo, not a fork of us. It contains our repo verbatim as `reference/parallel-frontend/`. It is ahead of us on backend truth and operations, behind us on frontend depth, testing, and framework currency.
|
||||
|
||||
This document is the working brief for whoever executes the harvest — including a future session of me. It states what is true today in this repo, what changes, and how each item is proven done.
|
||||
|
||||
---
|
||||
|
||||
## 2. Constraint that shapes everything
|
||||
|
||||
**This repo has no backend.** 530 `.ts` files, Angular 22, zero server code. Our backend exists only as 17 contract documents in `docs/backend/`, implemented by another team.
|
||||
|
||||
That splits every harvested improvement into one of five lanes:
|
||||
|
||||
| Lane | Meaning | Where it lands |
|
||||
|---|---|---|
|
||||
| **A — Frontend** | We write the code, this sprint | `src/`, `e2e/`, `angular.json`, CI |
|
||||
| **B — Contracts** | We write the requirement; backend implements | `docs/backend/*.md` |
|
||||
| **C — Packages** | Ships in `@marketplaces/auth` (external repo `vitanovaPackages`) | package repo + DI wiring here |
|
||||
| **D — Infra/Ops** | Deploy scripts and runbook | `scripts/deploy/`, `docs/DEPLOYMENT.md` |
|
||||
| **E — Process** | Governance, gates, policy | `../../backend/BACKEND-INTEGRATION.md`, ADRs |
|
||||
|
||||
Anything that would require us to stand up Prisma, Postgres, or NestJS in *this* repo is out of scope. It becomes a Lane B contract line instead.
|
||||
|
||||
---
|
||||
|
||||
## 3. What we verified in our own code (2026-08-21, current HEAD)
|
||||
|
||||
Their audit was written against our 11 Aug snapshot. Re-checked against today's code:
|
||||
|
||||
| Their finding | Status now | Evidence |
|
||||
|---|---|---|
|
||||
| Admin session cookie set by JS, readable via `document.cookie` | **Already fixed** | zero `document.cookie` hits in `src/` |
|
||||
| Admin JWT / refresh token in `localStorage` | **Already fixed** | no `setItem(*token*)` anywhere |
|
||||
| `http://ip-api.com` from an HTTPS storefront | **STILL LIVE** | `src/app/services/location.service.ts:75` |
|
||||
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **STILL LIVE** | `src/app/pages/cart/cart.component.ts:485` |
|
||||
| Client-side `authorization-key` / `userid-value` headers | **STILL LIVE** | `src/app/services/api.service.ts:675` |
|
||||
| Hardcoded partner ID in the bundle | **STILL LIVE** | `src/app/services/api.service.ts:143` — `'web-97ec-9c57-4dde-9037-3a68f7f83750'` |
|
||||
| `localStorage` as persistence | **19 files**, mostly admin facades + editor draft storage | see TODO FH-A7 |
|
||||
| Bundle 452 kB over a 700 kB budget | **Still true** | their measured build |
|
||||
| Zero `idempot*` in the codebase | **Still true** | no Idempotency-Key sent on payment creation |
|
||||
|
||||
Two of those are live bugs, not just security posture:
|
||||
|
||||
- **`http://ip-api.com`** — browsers block mixed active content on an HTTPS origin. `detectLocation()` therefore always takes its error branch in production. Region auto-detect has been silently dead.
|
||||
- **Bank URL in an iframe** — most acquirer 3-D Secure pages send `X-Frame-Options: DENY` / frame-ancestors CSP. The popup renders blank for those banks. Their spec calls this out explicitly: card checkout should navigate the current tab, not open an intermediate popup.
|
||||
|
||||
That reframes three of the "security" items as **defect fixes with a security benefit**, which is a much easier sell and a much better use of the sprint.
|
||||
|
||||
---
|
||||
|
||||
## 4. Selection rule — what counts as "an improvement"
|
||||
|
||||
An item is harvested only if it passes all four:
|
||||
|
||||
1. **It is better than what we have**, not merely different.
|
||||
2. **It survives without their backend.** Either we can build it, or it is a contract line the backend team can implement against.
|
||||
3. **It does not regress us.** Nothing that drops us to Angular 21, reintroduces mocks, or lowers our test bar.
|
||||
4. **It is falsifiable.** There is a test, a check, or an observable state that proves it done.
|
||||
|
||||
Explicitly rejected by this rule (from the analysis §9): their Angular version, their `mock-data.service.ts`, their 25-test/zero-e2e posture, their env-pinned `ORDER_MANAGER_MARKETPLACE_SLUG`, their hardcoded server IP, their narrower 5-type section schema.
|
||||
|
||||
---
|
||||
|
||||
## 5. The harvest, by theme
|
||||
|
||||
### 5.1 Correctness primitives (the highest-value cluster)
|
||||
|
||||
Three patterns from their backend that are worth more than everything else combined, because each replaces application logic with a database guarantee:
|
||||
|
||||
**Conditional-UPDATE reservation.** One statement is their entire oversell defence:
|
||||
|
||||
```sql
|
||||
UPDATE "MarketplaceInventory"
|
||||
SET "reserved" = "reserved" + $qty
|
||||
WHERE "marketplaceId" = $mp AND "variantId" = $variant
|
||||
AND ("onHand" - "reserved") >= $qty
|
||||
RETURNING "id"
|
||||
```
|
||||
|
||||
Empty result set → `409`. No read-then-write window, no advisory lock, no retry loop. Goes into `../../backend/BACKEND-INTEGRATION.md` as a normative requirement, not a suggestion.
|
||||
|
||||
**Idempotency as a unique constraint.** `Payment.idempotencyKey UNIQUE` and `PaymentWebhookEvent @@unique([provider, eventKey])`. A duplicate insert throws, and the catch returns `{accepted: true, duplicate: true}`. Replay protection becomes structurally impossible to forget, versus an `if` somebody eventually deletes. Goes into `PHASE-7`.
|
||||
|
||||
**Append-only inventory journal.** Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. This is the direct answer to the v3.1 plan's "we cannot explain your numbers" complaint — it makes every quantity reconstructible after the fact.
|
||||
|
||||
None of these are hard. All three are cheap to specify and expensive to retrofit.
|
||||
|
||||
### 5.2 Session and credential hygiene
|
||||
|
||||
Their model, which we adopt as the contract target: server-stored sessions, random 32 bytes, **stored as SHA-256 hash only**, HttpOnly + Secure + SameSite, revocable, one distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`), Argon2id `memoryCost 65536 / timeCost 3 / parallelism 1`, mandatory TOTP with a signed 10-minute setup token, and password change revoking every live session in the same transaction.
|
||||
|
||||
Plus the twelve-line CSRF defence we do not have: a global `onRequest` hook rejecting any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist.
|
||||
|
||||
Our side of this is subtractive: stop sending provider credentials from the browser, stop shipping a partner ID literal in the bundle.
|
||||
|
||||
### 5.3 Tenant and preview safety
|
||||
|
||||
Host → verified domain row → tenant, 30-second cache with explicit invalidation, `404` on unknown host with no fallback tenant. We have bootstrap-driven runtime config and no equivalent guarantee written down.
|
||||
|
||||
Their preview mechanism is the piece worth copying outright: an HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, delivered as a `storefront_preview` cookie, plus a global hook that returns `404 Preview mode is read-only` for any non-GET while that cookie is present. We have preview UI and no preview safety at all.
|
||||
|
||||
### 5.4 Publish, revisions, clone
|
||||
|
||||
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction, rollback creates a *new* revision rather than rewriting history. Clone copies design and catalog assignments, **forces inventory to zero**, and never copies domains, customers, orders, or secrets; its category walk is topological with explicit cycle detection.
|
||||
|
||||
### 5.5 Product ideas worth taking
|
||||
|
||||
- **Order-manager as a fully separate contour** — separate URL, shell, cookie, login, scoped to one marketplace, with no visibility into catalog, design, domains, or payment settings. Removes an entire permissions surface rather than guarding it.
|
||||
- **Digital goods in one table** — `FulfillmentMode: MANUAL | CODE_POOL`, a `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, codes revealed only once the order is `PAID`. We have no digital-goods story; this is a complete one.
|
||||
- **`DOMAIN_PENDING` as a real marketplace state**, not an error condition.
|
||||
- **CSV marketplace import with `dryRun` default true** — bulk tenant creation as a first-class operation.
|
||||
- **Their §22 acceptance list** as a ready-made e2e suite. Two of the fifteen are worth writing immediately: concurrent purchase of the last unit, and a replayed webhook.
|
||||
|
||||
### 5.6 Social identity — VK ID and Yandex ID
|
||||
|
||||
The archive has **zero** VK/Yandex/OAuth code; there is nothing to copy. What we take is the *session-issuing shape* of their Telegram flow and terminate VK/Yandex into it.
|
||||
|
||||
Design decisions, all of which belong in `@marketplaces/auth`:
|
||||
|
||||
1. **Provider-agnostic surface.** `SocialIdentityGateway` with a `SocialProvider` union, replacing today's VK-specific `VkIdGateway`. One controller pattern backend-side, one strategy object per provider.
|
||||
2. **Backend-owned PKCE.** Our current interface passes `codeVerifier` from the client, which forces the browser to generate and hold the verifier. We are a confidential client. The backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, and the browser only ever gets redirected. `completeCallback()` disappears from the frontend entirely.
|
||||
3. **VK ID gotcha:** the callback returns `device_id` next to `code`, and the token exchange fails without it. This is the single most common VK ID integration bug and it must be in the contract text.
|
||||
4. **Multi-tenant `redirect_uri` is a one-way door.** Both providers validate `redirect_uri` against an exact registered list; we cannot register one per tenant domain. Resolution: a single central identity host as the only registered callback, tenant carried inside the signed `state`, then a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for its session cookie. **This must be decided before any code is written.**
|
||||
5. **Identity conflict is not an upsert.** `@@unique([provider, providerUserId])` so the database refuses a silent rebind; conflicts route to controlled resolution.
|
||||
|
||||
Build order: provider-agnostic surface → VK ID → Yandex ID (a second strategy, roughly a day) → migrate Telegram onto `ExternalIdentity` → linking UI → email/phone OTP demoted to recovery.
|
||||
|
||||
### 5.7 Operations
|
||||
|
||||
Adopt: WAL archiving plus a *scheduled, proven* restore drill; a data network that is `internal: true` so "the database is not reachable from the internet" is structural rather than a firewall promise; host hardening we lack (fail2ban, sshd drop-in, sysctl).
|
||||
|
||||
Already better on our side, keep as-is: our `add-domain.sh` already pre-checks the DNS A record and runs `nginx -t` before and after; `server-setup.sh` already configures ufw. Their `provision-domain.sh` hardcodes the server IP — do not copy that shape.
|
||||
|
||||
### 5.8 Process
|
||||
|
||||
Their `DEVELOPER_HANDOFF.md` §7 is nine falsifiable invariants and is a better acceptance gate than anything currently in our delivery plan. Their PR policy (one functional area per PR; mandatory security impact and rollback plan; never touch payment/inventory/order state machines inside a redesign PR) and their release discipline ("a local build or the existence of a UI does not mean production readiness") are both worth adopting verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sequencing
|
||||
|
||||
Four waves. Each wave is independently shippable; nothing in a later wave blocks an earlier one.
|
||||
|
||||
**Wave 1 — Defect fixes with a security benefit (this sprint, Lane A).**
|
||||
The three live bugs: geo over HTTP, bank URL in an iframe, provider credentials and partner ID in the bundle. Plus `Idempotency-Key` on payment creation. All frontend, all provable, all things their audit will otherwise keep pointing at.
|
||||
|
||||
**Wave 2 — Contract hardening (Lane B, parallel with Wave 1).**
|
||||
Write the correctness primitives, session model, tenant/preview rules, revision semantics, and inventory journal into `docs/backend/`. Costs no engineering capacity from the frontend team and immediately raises the bar the backend is built to.
|
||||
|
||||
**Wave 3 — Proof (Lane A).**
|
||||
The two acceptance e2e tests, bundle budget as a blocking CI check, and the deployable split that gets us under budget.
|
||||
|
||||
**Wave 4 — Identity (Lane C).**
|
||||
Blocked on the central-identity-host decision. Provider-agnostic surface, VK ID, Yandex ID, Telegram migration, linking UI.
|
||||
|
||||
Ops (Lane D) and process (Lane E) run continuously alongside.
|
||||
|
||||
---
|
||||
|
||||
## 7. Explicitly out of scope
|
||||
|
||||
- Merging the two codebases, in either direction.
|
||||
- Reimplementing their backend here.
|
||||
- Adopting their section schema, their template list, or their backoffice.
|
||||
- Any dependency downgrade.
|
||||
- Removing our boundary checker, cycle check, or coverage floor to match their looser governance.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Contract lines in `docs/backend/` are written and never implemented | Pair each with an acceptance scenario in the handoff doc so it is a delivery gate, not a wish |
|
||||
| The central-identity-host decision slips and blocks all of Wave 4 | It is the first item in the TODO; escalate on day one |
|
||||
| Removing the client-side payment credential path breaks checkout before the server side exists | Confirm the server-priced checkout session path (already in `api.service.ts`) covers every live flow before deleting the legacy header path |
|
||||
| The deployable split is larger than estimated | Wave 3 item, not a blocker for Waves 1–2; can ship the bundle-budget CI check first and let it fail loudly |
|
||||
| Harvest is read as "they were right about everything" | The analysis records where they are behind us — tests, e2e, framework currency, frontend depth — and the TODO carries no item that regresses those |
|
||||
|
||||
---
|
||||
|
||||
## 9. Done means
|
||||
|
||||
- Every Wave 1 item has a test or an observable check proving it.
|
||||
- Every Lane B item exists as normative text in `docs/backend/` with an acceptance scenario attached.
|
||||
- The two §22 acceptance tests run in CI.
|
||||
- Bundle budget is a blocking check and the storefront is under it.
|
||||
- VK ID and Yandex ID both log a customer in through `@marketplaces/auth`, with the client never holding a secret, a token, or a code verifier.
|
||||
- No item in this harvest lowered our Angular version, our test count, or our architecture governance.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Frontend default bootstrap (unpublished-marketplace placeholder)
|
||||
|
||||
**Date:** 2026-08-22
|
||||
**Status:** approved (decided by project owner in-session, no further review requested)
|
||||
|
||||
## Problem
|
||||
|
||||
Production `/bootstrap` has no fallback today. A marketplace with no published revision either 404s or returns whatever partial row the backend has — frontend has nothing sane to render. Need a placeholder that shows immediately for any brand before its first publish, with every feature switched on so it doubles as a full product demo.
|
||||
|
||||
## Decision
|
||||
|
||||
Whole-object fallback, decided client-side from one explicit backend signal.
|
||||
|
||||
### 1. Backend contract change
|
||||
|
||||
Add one required top-level field to the `/bootstrap` response:
|
||||
|
||||
```ts
|
||||
interface BootstrapConfig {
|
||||
schemaVersion: string;
|
||||
generatedAt: string;
|
||||
published: boolean; // NEW — false until MarketplaceRevision.status = 'published'
|
||||
tenant: TenantConfig;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`published` mirrors whether the marketplace has a `publishedRevision` (see `MarketplaceRevision.status` in `BACKEND-INTEGRATION.md` §11) — not `lifecycleState` directly, since a marketplace can be `live` while a *new* draft revision sits unpublished. Backend still returns full real `tenant`/`branding`/etc when `published: true`; when `false` it may return anything or the last-known real data — frontend ignores every other field in that case (see §2).
|
||||
|
||||
### 2. Frontend: whole-object swap
|
||||
|
||||
New constant, colocated with the other `DEFAULT_*` config constants:
|
||||
|
||||
```ts
|
||||
// src/app/shared/models/config/default-bootstrap.const.ts
|
||||
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
|
||||
schemaVersion: '1.0.0',
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
published: false,
|
||||
tenant: { /* generic placeholder — brandName 'Marketplace', no real domain */ },
|
||||
branding: { brandName: 'Marketplace', ... },
|
||||
theme: { /* the existing default-light palette from bootstrap.json */ },
|
||||
featureFlags: { wishlist: true, compare: true, reviews: true, blog: true, chat: true,
|
||||
analytics: true, notifications: true, coupons: true, loyalty: true,
|
||||
giftCards: true, invoices: true }, // everything ON
|
||||
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG, // reused, already all-true
|
||||
header: DEFAULT_HEADER_CONFIG, // reused
|
||||
modules: DEFAULT_PLATFORM_MODULES_CONFIG, // reused (sellerManagement off — real module gate, not a feature flag)
|
||||
navigation: { /* hardcoded generic nav */ },
|
||||
pages: [ /* hardcoded generic home page, hero+categories+featured, same shape as bootstrap.json */ ],
|
||||
staticPages: { /* generic about/privacy/terms/contacts */ },
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
`ConfigService.loadBootstrap()` gains one check after the provider emits:
|
||||
|
||||
```ts
|
||||
tap(config => {
|
||||
const resolved = config.published ? config : DEFAULT_BOOTSTRAP;
|
||||
this.bootstrapSnapshot = resolved;
|
||||
this.revisionState.update(v => v + 1);
|
||||
}),
|
||||
```
|
||||
|
||||
No change to `ApiBootstrapProvider`, `MockBootstrapProvider`, or the `ConfigProvider` interface — the swap is a `ConfigService`-only concern, so it applies uniformly regardless of provider mode.
|
||||
|
||||
### 3. Error handling
|
||||
|
||||
- `published` missing/undefined from an old backend response → treat as `true` (backwards compatible: existing marketplaces that never send the field keep behaving exactly as today, same pattern already used for `modules`/ADR-011).
|
||||
- Actual HTTP failure (network error, 5xx) stays a hard error — `catchError` behavior unchanged, no fallback. Fallback is only for the *known* "not published yet" case, not for "backend unreachable." (Matches your earlier answer: explicit signal, not HTTP-status-driven.)
|
||||
|
||||
### 4. Testing
|
||||
|
||||
- `ConfigService` unit test: `published: false` response → snapshot equals `DEFAULT_BOOTSTRAP`.
|
||||
- `ConfigService` unit test: `published: true` → snapshot equals the real response, untouched.
|
||||
- `ConfigService` unit test: `published` absent → snapshot equals the real response (back-compat).
|
||||
- `DEFAULT_BOOTSTRAP` itself: a schema-shape test (it must satisfy `BootstrapConfig` — TypeScript already enforces this at compile time, so this is really just "does it compile").
|
||||
- One E2E smoke: a marketplace with no revision renders the placeholder home page without erroring.
|
||||
|
||||
## Out of scope (explicitly deferred)
|
||||
|
||||
- Field-level merge (real brand name + placeholder theme) — rejected in favor of simpler whole-object swap.
|
||||
- Any admin-panel UI for previewing/editing the default — not asked for.
|
||||
- Backend implementation of `published` resolution logic — backend team's own call once they build the service; this spec only fixes the wire contract.
|
||||
137
docs/superpowers/specs/superuser.md
Normal file
137
docs/superpowers/specs/superuser.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Platform Super-Admin — Phase 1 Design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Audience:** Internal admin & risk team ("super puper user")
|
||||
|
||||
## Purpose
|
||||
|
||||
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
|
||||
platform, drill into one, and review its access list, audit log, admin edit history, and
|
||||
purchase history. Read-only in this phase.
|
||||
|
||||
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
|
||||
this store's admin" toggle) is explicitly **out of scope** for this phase — see
|
||||
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
|
||||
notify plumbing depends on the tenant-context switch this phase builds.
|
||||
|
||||
## Non-goals (Phase 1)
|
||||
|
||||
- No editing of any tenant's data.
|
||||
- No impersonation of a store's admin.
|
||||
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
|
||||
don't exist yet).
|
||||
- No real backend — this repo is frontend-only; the backend contract is specified here for
|
||||
whoever owns that service, not implemented here.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New top-level feature module: `src/app/features/platform-admin/`.
|
||||
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
|
||||
`/admin/**` — a project is not "logged into" the way a store admin is.
|
||||
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
|
||||
`core/admin-auth/admin-auth.guard.ts`).
|
||||
- `PlatformAuthService` — session/login state for the super-admin, backed by a
|
||||
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
|
||||
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
|
||||
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
|
||||
never committed, never present in a production build path.
|
||||
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
|
||||
interface, no caller changes needed.
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
interface PlatformProjectSummary {
|
||||
id: UUID;
|
||||
name: string;
|
||||
slug: string;
|
||||
host: string;
|
||||
status: 'active' | 'suspended';
|
||||
createdAt: number;
|
||||
adminCount: number;
|
||||
lastActivityAt: number | null;
|
||||
}
|
||||
|
||||
interface PlatformProjectAccessEntry {
|
||||
userId: UUID;
|
||||
displayName: string;
|
||||
telegramUsername: string;
|
||||
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
|
||||
}
|
||||
|
||||
type PlatformProjectHistoryEntry =
|
||||
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
|
||||
```
|
||||
|
||||
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
|
||||
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
|
||||
aggregation is a backend concern later.
|
||||
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
|
||||
`core/auth/models/permission.model.ts` — no new role system.
|
||||
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
|
||||
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
|
||||
aggregation from existing per-tenant sources (e.g. the pattern in
|
||||
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
|
||||
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
|
||||
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
|
||||
changed.
|
||||
|
||||
## Components / pages
|
||||
|
||||
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
|
||||
last activity. Search/filter by status.
|
||||
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
|
||||
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
|
||||
- **Audit Log** — `history` filtered to `kind: 'access'`.
|
||||
- **Edit History** — `history` filtered to `kind: 'edit'`.
|
||||
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
|
||||
- All read-only in this phase.
|
||||
|
||||
## Security
|
||||
|
||||
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
|
||||
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
|
||||
enforcement must happen server-side once the backend endpoint exists. This is called out
|
||||
explicitly so it's never mistaken for the source of truth.
|
||||
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
|
||||
git-ignored local file; production auth goes through the real backend endpoint below.
|
||||
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
|
||||
sessions — higher-privilege session, smaller blast radius if a session is left open).
|
||||
- Every super-admin action (including read-only views) is itself audit-logged.
|
||||
- After implementation, run `/security-audit` on this feature specifically before it ships.
|
||||
|
||||
### Backend contract (for whoever owns that service — not implemented in this repo)
|
||||
|
||||
Add to `BACKEND-API-REFERENCE.md`:
|
||||
|
||||
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
|
||||
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
|
||||
artifact.
|
||||
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
|
||||
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
|
||||
that tenant, paginated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
|
||||
mapping logic.
|
||||
- No E2E in this phase — no real backend to exercise end-to-end yet.
|
||||
|
||||
## Phase 2 (out of scope here)
|
||||
|
||||
A separate spec/plan cycle, once Phase 1 ships:
|
||||
|
||||
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
|
||||
module (products, orders, categories, settings, etc.), reusing those modules under a
|
||||
tenant-context switch.
|
||||
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
|
||||
unchecked**. Uses the existing in-app notification pattern (the one behind
|
||||
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
|
||||
in their notification feed. Unchecked-by-default matters: some super-admin edits are
|
||||
discreet technical fixes where alerting the store admin would be noise or a reputational
|
||||
concern, not every edit should ping them.
|
||||
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
|
||||
establishes, which is why it's sequenced after.
|
||||
39
e2e/README.md
Normal file
39
e2e/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# E2E — Playwright
|
||||
|
||||
Track Q (`docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`, Q1). None of this existed before 2026-08-18.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm run e2e # headless, boots the dev server automatically
|
||||
npm run e2e:ui # interactive runner
|
||||
npm run e2e:report # last HTML report
|
||||
```
|
||||
|
||||
Against a different server (staging, a locally-started backend):
|
||||
|
||||
```bash
|
||||
BASE_URL=https://staging.example.com npm run e2e
|
||||
```
|
||||
|
||||
## What this suite currently covers, and what it doesn't
|
||||
|
||||
`environment.ts` ships `useMockData: false` — the dev server this suite boots hits real `/api/` endpoints, which 404 (`../docs/backend/BACKEND-INTEGRATION.md` — no backend is running anywhere this session can reach). The product catalog itself renders from a separate mocked bootstrap/catalog path (`useMockBootstrapOnLocal: true`), so real prices and real currency conversion ARE exercised — `smoke.spec.ts` explicitly ignores the expected 404 console noise rather than pretending it isn't there.
|
||||
|
||||
**This is not the same guarantee as running against a live backend.** Checkout, payment, and anything behind a real endpoint are not covered until `BASE_URL` points at a live environment. Confirmed once, concretely: on the first run, this suite caught a real bug (`@marketplaces/auth` shipping without Angular Ivy metadata, breaking app bootstrap) and a real test defect (a duplicate hidden dropdown made the first currency-switch attempt click a no-op element) — both fixed as part of standing this suite up. See the commit history in `src/main.ts` and this directory for what each was.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `../docs/backend/BACKEND-INTEGRATION.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10–F16 in the frontend backlog), specifically so that rewrite has a net under it. |
|
||||
| `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. |
|
||||
| `admin-dev-bypass.spec.ts` | `?devBypassAdmin=true` actually reaches the admin shell without a Telegram login (Track Q F59). |
|
||||
| `checkout-request-shape.spec.ts` | The amount actually charged must be computed server-side, never sent by the client (`PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5.2). Was red for a real bug, not a harness issue — root-caused 2026-08-21, see the `fakeCustomerSession` comment and `api-headers.interceptor.ts`. |
|
||||
| `checkout-idempotent-click.spec.ts` | Double-clicking checkout sends exactly one checkout-session request (Track Q F62). Same root cause and fix as above. |
|
||||
|
||||
## Adding a test
|
||||
|
||||
- Prefer existing CSS classes / ARIA roles already in the templates (`.currency-button`, `role="option"`, etc.) over inventing new selectors — there are no `data-testid` attributes in this codebase yet, and adding them project-wide is out of scope for this suite.
|
||||
- One behavior per test. Name the file after the behavior, not the page.
|
||||
- If a test needs backend state that mock data can't produce, mark it `test.skip(!process.env.BASE_URL, 'needs a live backend')` rather than deleting it — it documents the gap.
|
||||
27
e2e/admin-dev-bypass.spec.ts
Normal file
27
e2e/admin-dev-bypass.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Track Q Q2 / frontend backlog F59: past "verified live" admin claims were
|
||||
* code-inspection only, because /backoffice needs a real Telegram login this
|
||||
* suite cannot perform. ?devBypassAdmin=true (src/app/app.ts, gated by
|
||||
* Angular's isDevMode() at runtime in @marketplaces/auth's
|
||||
* AdminAuthService.devBypassLogin - not just build-time, and a no-op in any
|
||||
* production build) is the existing, already-shipped answer - this test just
|
||||
* proves it actually gets an E2E run into the admin shell.
|
||||
*/
|
||||
test.describe('admin dev bypass', () => {
|
||||
test('?devBypassAdmin=true reaches the admin shell without a Telegram login', async ({ page }) => {
|
||||
await page.goto('/?devBypassAdmin=true');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The bypass alone doesn't navigate anywhere - it only activates the
|
||||
// session, so the admin surface has to be reached directly afterwards.
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// A real Telegram-gated admin route would redirect to a login dialog;
|
||||
// reaching dashboard content is the actual proof the bypass worked.
|
||||
await expect(page).not.toHaveURL(/login/i);
|
||||
await expect(page.locator('body')).not.toContainText(/scan.*qr|log in with telegram/i);
|
||||
});
|
||||
});
|
||||
78
e2e/checkout-idempotent-click.spec.ts
Normal file
78
e2e/checkout-idempotent-click.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Track Q Q5 / frontend backlog F62: "repeat webhook and double-click create
|
||||
* exactly one order." The webhook-idempotency half is a backend contract
|
||||
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6.3, provider + providerEventId as
|
||||
* the dedup key) this suite cannot exercise without a live backend. This
|
||||
* test covers the half that IS frontend-testable: a double-click on the
|
||||
* checkout button must not fire two checkout-session requests.
|
||||
*/
|
||||
|
||||
const FAKE_ITEM = {
|
||||
categoryID: 1, itemID: 5151, name: 'Idempotency Test Item', photos: null,
|
||||
description: '', currency: 'RUB', price: 500, discount: 0, rating: 0,
|
||||
callbacks: null, questions: null, quantity: 1,
|
||||
};
|
||||
|
||||
test('double-clicking checkout sends exactly one checkout-session request', async ({ page, context }) => {
|
||||
await page.addInitScript(item => {
|
||||
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||
}, FAKE_ITEM);
|
||||
|
||||
// Root-caused and fixed 2026-08-21 - see checkout-request-shape.spec.ts's
|
||||
// fakeCustomerSession comment and api-headers.interceptor.ts.
|
||||
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]);
|
||||
await page.route('**/users/sessions/**', route =>
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ sessionId: 'e2e-fake-session', status: 'active', username: 'e2e_user', userId: 1 }),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v2/pricing/fx-quote**', route =>
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ quoteId: 'fxq_e2e', base: 'RUB', quote: 'RUB', rate: 1, source: 'e2e', observedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 300000).toISOString() }),
|
||||
}),
|
||||
);
|
||||
|
||||
let checkoutRequestCount = 0;
|
||||
await page.route('**/api/v2/storefront/checkout', async (route: Route) => {
|
||||
checkoutRequestCount += 1;
|
||||
// Deliberately slow, so a real double-click's second event has to land
|
||||
// while the first request is still in flight - the exact race this test
|
||||
// exists to catch.
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
checkoutSessionId: 'chk_e2e_idempotent',
|
||||
lines: [{ offerId: String(FAKE_ITEM.itemID), qty: 1, unitPrice: { amountMinor: 50000, currency: 'RUB' }, lineTotal: { amountMinor: 50000, currency: 'RUB' }, priceSnapshotId: 'snap_e2e' }],
|
||||
subtotal: { amountMinor: 50000, currency: 'RUB' }, discount: { amountMinor: 0, currency: 'RUB' },
|
||||
delivery: { amountMinor: 0, currency: 'RUB' }, total: { amountMinor: 50000, currency: 'RUB' },
|
||||
fxQuoteId: 'fxq_e2e', expiresAt: new Date(Date.now() + 300000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v2/storefront/payments/intents', route =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ qrId: 'qr_e2e', nspkurl: 'https://example.com/pay', qrTTL: 5 }) }),
|
||||
);
|
||||
|
||||
await page.goto('/cart');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// No <label for="terms-checkbox"> exists in the markup - the checkbox and
|
||||
// its text share a plain clickable wrapper - so toggle the input directly.
|
||||
await page.locator('#terms-checkbox').dispatchEvent('click');
|
||||
await expect(page.locator('#terms-checkbox')).toBeChecked();
|
||||
|
||||
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
||||
await expect(qrButton).toBeEnabled({ timeout: 10_000 });
|
||||
await qrButton.dblclick();
|
||||
|
||||
// Give the deliberately slow mock time to resolve and for any second,
|
||||
// erroneously-fired request to have landed.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(checkoutRequestCount, 'a double-click must not create two checkout sessions').toBe(1);
|
||||
});
|
||||
201
e2e/checkout-request-shape.spec.ts
Normal file
201
e2e/checkout-request-shape.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Guards the specific contract this rewrite exists to enforce
|
||||
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged
|
||||
* must be computed server-side, never sent by the client. Before this
|
||||
* rewrite, POST /cart carried a client-computed `amount` the backend was
|
||||
* asked to trust.
|
||||
*
|
||||
* cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts
|
||||
* exists), so this E2E test is the only coverage the checkout request shape
|
||||
* has. Scoped narrowly on purpose: cart state is seeded directly into
|
||||
* localStorage and the customer session is faked via cookie + intercepted
|
||||
* session-check, rather than driving a full add-to-cart-then-login UI
|
||||
* journey - that journey is real product surface worth its own test, but
|
||||
* would make this test about navigation, not about what it exists to prove.
|
||||
*/
|
||||
|
||||
const FAKE_SESSION_ID = 'e2e-fake-session';
|
||||
const FAKE_ITEM = {
|
||||
categoryID: 1,
|
||||
itemID: 4242,
|
||||
name: 'E2E Test Item',
|
||||
photos: null,
|
||||
description: '',
|
||||
currency: 'RUB',
|
||||
price: 1000,
|
||||
discount: 0,
|
||||
rating: 0,
|
||||
callbacks: null,
|
||||
questions: null,
|
||||
quantity: 2,
|
||||
};
|
||||
|
||||
test.describe('checkout request shape', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await seedCart(page);
|
||||
await fakeCustomerSession(page, context);
|
||||
await mockFxQuoteEndpoint(page);
|
||||
});
|
||||
|
||||
test('checkout session request carries offers and qty, never amount or price', async ({ page }) => {
|
||||
const checkoutRequest = interceptCheckoutSession(page);
|
||||
|
||||
await page.goto('/cart');
|
||||
await acceptTermsAndCheckout(page);
|
||||
|
||||
const body = await checkoutRequest;
|
||||
|
||||
expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount');
|
||||
expect(body, 'must never send a client-computed price').not.toHaveProperty('price');
|
||||
expect(Array.isArray(body.offers), 'must send an offers array').toBe(true);
|
||||
expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity });
|
||||
});
|
||||
|
||||
test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => {
|
||||
interceptCheckoutSession(page); // must resolve for the intent call to fire at all
|
||||
const intentRequest = interceptPaymentIntent(page);
|
||||
|
||||
await page.goto('/cart');
|
||||
await acceptTermsAndCheckout(page);
|
||||
|
||||
const body = await intentRequest;
|
||||
|
||||
// Payment creation now goes through @marketplaces/payment
|
||||
// (MARKETPLACES_PAYMENT_GATEWAY -> POST {qrApiUrl}/api/v1/payments),
|
||||
// not api.service.ts's superseded createPaymentIntent - see
|
||||
// cart.component.ts's createPaymentIntent() comment.
|
||||
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
|
||||
expect(body).not.toHaveProperty('amount');
|
||||
const metadata = body.metadata as Record<string, string> | undefined;
|
||||
expect(typeof metadata?.merchantReference).toBe('string');
|
||||
expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedCart(page: Page): Promise<void> {
|
||||
await page.addInitScript(item => {
|
||||
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||
}, FAKE_ITEM);
|
||||
}
|
||||
|
||||
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
|
||||
// Root-caused and fixed 2026-08-21 (see api-headers.interceptor.ts):
|
||||
// apiHeadersInterceptor injected AuthService to attach a WebSessionID
|
||||
// header, but AuthService's own constructor makes the exact
|
||||
// GET /users/sessions/:id call this interceptor runs on, which threw
|
||||
// NG0200 (circular dependency) mid-construction on every page load -
|
||||
// swallowed silently, read as "session invalid," cookie cleared
|
||||
// immediately. The { url } cookie form below is unrelated to that bug but
|
||||
// is still the more correct form, so it stays.
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'webSessionID',
|
||||
value: FAKE_SESSION_ID,
|
||||
url: 'http://localhost:4200',
|
||||
},
|
||||
]);
|
||||
|
||||
// Matches TelegramSessionApiService.normalizeWebSession's expected shape.
|
||||
await page.route('**/users/sessions/**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sessionId: FAKE_SESSION_ID,
|
||||
status: 'active',
|
||||
username: 'e2e_user',
|
||||
userId: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||
await page.route('**/api/v2/pricing/fx-quote**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
quoteId: 'fxq_e2e',
|
||||
base: 'RUB',
|
||||
quote: 'RUB',
|
||||
rate: 1,
|
||||
source: 'e2e-fixture',
|
||||
observedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
page.route('**/api/v2/storefront/checkout', (route: Route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
resolve(body);
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
checkoutSessionId: 'chk_e2e_fixture',
|
||||
lines: [{
|
||||
offerId: String(FAKE_ITEM.itemID),
|
||||
qty: FAKE_ITEM.quantity,
|
||||
unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' },
|
||||
lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
priceSnapshotId: 'snap_e2e',
|
||||
}],
|
||||
subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
discount: { amountMinor: 0, currency: 'RUB' },
|
||||
delivery: { amountMinor: 0, currency: 'RUB' },
|
||||
total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
fxQuoteId: 'fxq_e2e',
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
// @marketplaces/payment: apiUrl (qrApiUrl with its trailing /api
|
||||
// stripped, see app.config.ts) + default paymentsPath '/api/v1/payments'.
|
||||
page.route('**/api/v1/payments', (route: Route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
resolve(body);
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paymentId: 'qr_e2e_fixture',
|
||||
method: 'qr',
|
||||
status: 'pending',
|
||||
action: { type: 'qr', url: 'https://example.com/pay/e2e' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptTermsAndCheckout(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// #terms-checkbox is a custom-styled input (zero-size native element, a
|
||||
// <label> renders the visible box) - .check() refuses on geometry even
|
||||
// with force:true, so toggle it via its label the way a real user would.
|
||||
const termsCheckbox = page.locator('#terms-checkbox');
|
||||
if (await termsCheckbox.count() > 0) {
|
||||
const label = page.locator('label[for="terms-checkbox"]');
|
||||
if (await label.count() > 0) {
|
||||
await label.click();
|
||||
} else {
|
||||
await termsCheckbox.dispatchEvent('click');
|
||||
}
|
||||
}
|
||||
|
||||
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
||||
await qrButton.click();
|
||||
}
|
||||
124
e2e/currency-switch.spec.ts
Normal file
124
e2e/currency-switch.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Track Q Q4: currency switch must recalculate by FX quote. Explicitly,
|
||||
* "160 RUB" must not become "160 USD" - the number has to change, not just
|
||||
* the label next to it.
|
||||
*
|
||||
* Written before the checkout money-truth rewrite (frontend backlog F10-F16,
|
||||
* which deletes CurrencyRatesService's client-side float math and switches
|
||||
* checkout to a server-computed total per
|
||||
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5). This test exists so
|
||||
* that rewrite has something to break loudly if it silently stops converting.
|
||||
*
|
||||
* This session has no live backend to run against, so GET
|
||||
* /api/v2/pricing/fx-quote is intercepted with a response shaped exactly per
|
||||
* contract §3.1. That exercises the REAL code path - FxQuoteApiGateway,
|
||||
* CurrencyRatesService, the currencyConvert pipe - rather than switching the
|
||||
* whole app into mock mode, which would test a different (mock) gateway
|
||||
* instead of the one actually shipped.
|
||||
*/
|
||||
|
||||
/** Rate relative to RUB, only what this test needs. */
|
||||
const MOCK_RATE: Record<string, number> = { USD: 0.0108, EUR: 0.0092, AMD: 4.31 };
|
||||
|
||||
test.describe('currency switch', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockFxQuoteEndpoint(page);
|
||||
});
|
||||
|
||||
test('switching currency changes the displayed price value, not just its label', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const priceLocator = page.locator('.current-price, .original-price').first();
|
||||
await expect(priceLocator).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const before = await readPrice(priceLocator);
|
||||
expect(before.value, 'a price must be a real positive number before switching').toBeGreaterThan(0);
|
||||
|
||||
await switchCurrency(page, before.currency === 'USD' ? 'RUB' : 'USD');
|
||||
|
||||
// The currency LABEL flips synchronously (a signal write), but the rate
|
||||
// itself arrives from the mocked network call asynchronously - polling
|
||||
// only the label races ahead of the actual conversion and passes before
|
||||
// the number has caught up. Poll the parsed numeric value instead, since
|
||||
// that is what this test exists to guard.
|
||||
await expect
|
||||
.poll(async () => (await readPrice(priceLocator)).value, {
|
||||
message: 'price value never diverged from the pre-switch amount',
|
||||
})
|
||||
.not.toBeCloseTo(before.value, 2);
|
||||
|
||||
const after = await readPrice(priceLocator);
|
||||
|
||||
expect(after.currency, 'the currency label must actually change').not.toBe(before.currency);
|
||||
// The literal regression this test exists to catch: a rate of 1 disguised
|
||||
// as a real conversion. RUB->USD or USD->RUB is never a 1:1 rate.
|
||||
expect(after.value, `${before.value} ${before.currency} must not equal ${after.value} ${after.currency}`).not.toBeCloseTo(before.value, 2);
|
||||
});
|
||||
|
||||
test('an out-of-range rate must not silently pass as valid', async ({ page }) => {
|
||||
// Guards the specific bad-data class this suite exists to catch: a
|
||||
// conversion that returns something implausible (zero, negative, or
|
||||
// absurdly large) instead of erroring visibly.
|
||||
await page.goto('/');
|
||||
|
||||
const priceLocator = page.locator('.current-price, .original-price').first();
|
||||
await expect(priceLocator).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const { value } = await readPrice(priceLocator);
|
||||
|
||||
expect(value).toBeGreaterThan(0);
|
||||
expect(value).toBeLessThan(100_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||
await page.route('**/api/v2/pricing/fx-quote**', (route: Route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const base = url.searchParams.get('base') ?? 'RUB';
|
||||
const quote = url.searchParams.get('quote') ?? 'USD';
|
||||
const rate = MOCK_RATE[quote] ?? 1;
|
||||
const now = new Date();
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
quoteId: `fxq_e2e_${base}_${quote}_${now.getTime()}`,
|
||||
base,
|
||||
quote,
|
||||
rate,
|
||||
source: 'e2e-fixture',
|
||||
observedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function switchCurrency(page: Page, targetCode: string): Promise<void> {
|
||||
// The page renders more than one language-selector instance (desktop/mobile
|
||||
// variants share the same markup) - scoping to the dropdown that actually
|
||||
// carries the "open" class avoids clicking an option in a hidden duplicate,
|
||||
// which is silently a no-op rather than a failure.
|
||||
const trigger = page.locator('.currency-button:visible').first();
|
||||
await trigger.click();
|
||||
|
||||
const openDropdown = page.locator('.currency-dropdown.open').first();
|
||||
await expect(openDropdown).toBeVisible();
|
||||
|
||||
await openDropdown.locator('.currency-option', { hasText: targetCode }).first().click();
|
||||
}
|
||||
|
||||
async function readPrice(locator: import('@playwright/test').Locator): Promise<{ value: number; currency: string }> {
|
||||
const text = (await locator.textContent()) ?? '';
|
||||
// Matches "1 234.56 USD" / "1234.56 ₽" shapes the price templates render.
|
||||
const match = text.replace(/\s/g, '').match(/([\d.,]+)([A-Z]{3}|\D+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`could not parse price text: "${text}"`);
|
||||
}
|
||||
const value = Number(match[1].replace(/,/g, ''));
|
||||
return { value, currency: match[2] };
|
||||
}
|
||||
53
e2e/smoke.spec.ts
Normal file
53
e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/** First E2E test in this repo. If this fails, nothing else in the suite matters. */
|
||||
test.describe('smoke', () => {
|
||||
test('storefront boots with no console errors', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
// pageerror catches uncaught exceptions - always a real bug, always kept.
|
||||
page.on('pageerror', err => errors.push(err.message));
|
||||
page.on('console', msg => {
|
||||
if (msg.type() !== 'error') {
|
||||
return;
|
||||
}
|
||||
// "Failed to load resource" is Chrome's own message for a failed
|
||||
// network request (404/502/etc), not application code. With
|
||||
// environment.useMockData: false and no live backend behind this dev
|
||||
// server (docs/backend/BACKEND-HANDOFF.md), every /api/ call 404s by
|
||||
// design - that is a backend-availability fact, not something this
|
||||
// smoke test exists to catch. A real app-level console.error still
|
||||
// fails this test.
|
||||
if (/^Failed to load resource/.test(msg.text())) {
|
||||
return;
|
||||
}
|
||||
errors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
// Give the bootstrap fetch + first render cycle time to settle before
|
||||
// asserting on the error list, or this is a race against app.config.ts.
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(errors, `console errors on first paint: ${errors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
|
||||
// This suite runs against the mock-data build (see comment at the top of
|
||||
// playwright.config.ts), so MockBootstrapProvider fetches this static
|
||||
// asset rather than a live /bootstrap endpoint - that's the URL to
|
||||
// intercept here, not the real API path.
|
||||
await page.route('**/assets/mock/bootstrap/bootstrap.json', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ module.exports = function (config) {
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
],
|
||||
browsers: ['ChromeHeadlessNoSandbox'],
|
||||
customLaunchers: {
|
||||
@@ -20,7 +21,26 @@ module.exports = function (config) {
|
||||
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
},
|
||||
},
|
||||
reporters: ['progress'],
|
||||
reporters: ['progress', 'coverage'],
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, 'coverage'),
|
||||
subdir: '.',
|
||||
reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }],
|
||||
// Floor set 2026-08-18, ~5 points below the measured level right
|
||||
// after this session's facade-test pass (43.3%/29.0%/34.1%/43.7%
|
||||
// statements/branches/functions/lines) - a deliberate floor per the
|
||||
// delivery plan's Q9 ("deliberately unset today"), not an aspiration.
|
||||
// Ratchet this UP as coverage grows; a PR that drops below it should
|
||||
// fail CI, not get merged with a lower number quietly re-baselined in.
|
||||
check: {
|
||||
global: {
|
||||
statements: 40,
|
||||
branches: 25,
|
||||
functions: 30,
|
||||
lines: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
restartOnFileChange: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,6 +93,8 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||
}
|
||||
|
||||
# Template for onboarding a new marketplace tenant.
|
||||
@@ -178,4 +180,6 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||
}
|
||||
|
||||
@@ -30,9 +30,7 @@
|
||||
{
|
||||
"name": "api-cache",
|
||||
"urls": [
|
||||
"/api/**",
|
||||
"https://api.dexarmarket.ru:445/**",
|
||||
"https://api.novo.market:444/**"
|
||||
"/api/**"
|
||||
],
|
||||
"cacheConfig": {
|
||||
"maxSize": 100,
|
||||
|
||||
1569
package-lock.json
generated
1569
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user