From 16dec127c93bfb9bc3d0a5e1615bd7b380f9af5d Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 19 Jul 2026 07:53:47 +0400 Subject: [PATCH] refactor(core): prepare frontend for backend integration Co-Authored-By: Claude Fable 5 - Fix AdminLayoutComponent.readRouteData crash: leaf route snapshot/data is now optional with dashboard-title fallback, so admin deep links never crash the shell when route metadata is missing (root cause of every blocked browser test since Sprint 12) - Never-settling promises fixed: all gateway ensureData() bridges (products, categories, moderation) and the catalog category resolver now resolve with an empty list on transport failure instead of hanging forever, so API outages surface as empty states with guidance rather than permanent skeletons plus global console errors - Request de-duplication: BackofficeDataService caches products/categories with shareReplay - one in-flight request per endpoint shared by all consuming gateways (was 5+ duplicate requests per admin page load); failures clear the cache so the next call retries - Gateway contract audit: all 8 admin gateways (products, categories, orders, customers/moderation, transactions, users, dashboard metrics, monitoring) now implement an explicit *Gateway interface - added the missing AdminMonitoringGateway; media already swaps via the abstract MediaRepository DI class - Mock mode untouched: provider selection still flows through RuntimeProviderStrategyService/BACKOFFICE_DATA_PROVIDER --- .../backoffice/backoffice-data.service.ts | 35 +++++++++++++++++-- .../admin-categories-local.gateway.ts | 5 ++- .../admin-moderation-local.gateway.ts | 5 ++- .../admin-monitoring-gateway.interface.ts | 14 ++++++++ .../admin-monitoring-local.gateway.ts | 3 +- .../services/admin-products-local.gateway.ts | 12 +++++-- .../admin/shell/admin-layout.component.ts | 5 ++- .../containers/catalog-container.component.ts | 4 ++- 8 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts diff --git a/src/app/core/backoffice/backoffice-data.service.ts b/src/app/core/backoffice/backoffice-data.service.ts index 78bf6a7..43b96b6 100644 --- a/src/app/core/backoffice/backoffice-data.service.ts +++ b/src/app/core/backoffice/backoffice-data.service.ts @@ -1,17 +1,46 @@ import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; +import { catchError, shareReplay } from 'rxjs/operators'; import { ProductCardConfig, CategoryCardConfig } from '../../shared/models/ui'; import { BACKOFFICE_DATA_PROVIDER } from './backoffice-data-provider.token'; +/** + * Single access point for backoffice catalog data. Requests are cached with + * shareReplay so the many gateways that read products/categories (products, + * categories, moderation, dashboard metrics) share one in-flight request per + * endpoint instead of each firing their own. A failed request clears the cache + * so the next call retries instead of replaying the error forever. + */ @Injectable({ providedIn: 'root' }) export class BackofficeDataService { private readonly provider = inject(BACKOFFICE_DATA_PROVIDER); + private products$: Observable | null = null; + private categories$: Observable | null = null; + loadProducts(): Observable { - return this.provider.loadProducts(); + if (!this.products$) { + this.products$ = this.provider.loadProducts().pipe( + catchError(error => { + this.products$ = null; + return throwError(() => error); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); + } + return this.products$; } loadCategories(): Observable { - return this.provider.loadCategories(); + if (!this.categories$) { + this.categories$ = this.provider.loadCategories().pipe( + catchError(error => { + this.categories$ = null; + return throwError(() => error); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); + } + return this.categories$; } } diff --git a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts index 95456f0..70c06d9 100644 --- a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts +++ b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts @@ -79,7 +79,10 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway { return; } - const categories = await new Promise(resolve => this.backofficeData.loadCategories().subscribe(value => resolve(value))); + // Empty list on transport failure - never leave the promise unsettled. + const categories = await new Promise(resolve => + this.backofficeData.loadCategories().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); this.cache = categories.map(category => this.toAdminCategory(category)); } diff --git a/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts b/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts index dc21c08..b9533d0 100644 --- a/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts +++ b/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts @@ -129,7 +129,10 @@ export class AdminModerationLocalGateway implements AdminModerationGateway { if (this.cache && this.reportsCache) { return; } - this.products = await new Promise(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value))); + // Empty list on transport failure - never leave the promise unsettled. + this.products = await new Promise(resolve => + this.backofficeData.loadProducts().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); this.cache = Array.from({ length: SEED_COUNT }, (_, index) => this.seedReview(index)); this.reportsCache = this.seedReports(); } diff --git a/src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts b/src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts new file mode 100644 index 0000000..70c9a99 --- /dev/null +++ b/src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; +import { AdminMonitoringEvent, AdminMonitoringEventFilters, AdminQueue, AdminWebhookDelivery } from '../models/admin-monitoring.model'; + +/** + * Contract for monitoring data sources. AdminMonitoringLocalGateway implements + * this today; a future HTTP gateway implements the same shape so the facade + * and page stay untouched when the backend lands (same pattern as + * AdminProductsGateway / AdminOrdersGateway). + */ +export interface AdminMonitoringGateway { + loadEvents(filters: AdminMonitoringEventFilters): Observable; + loadQueues(): Observable; + loadWebhooks(): Observable; +} diff --git a/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts b/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts index 1b31f5e..26aea0e 100644 --- a/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts +++ b/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts @@ -9,13 +9,14 @@ import { AdminQueue, AdminWebhookDelivery, } from '../models/admin-monitoring.model'; +import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface'; const CATEGORIES: AdminMonitoringCategory[] = ['audit', 'security', 'login', 'failed_login', 'api', 'error', 'warning']; const ACTORS = ['karen@dexar.market', 'anna@dexar.market', 'system', 'unknown']; const EVENT_COUNT = 40; @Injectable({ providedIn: 'root' }) -export class AdminMonitoringLocalGateway { +export class AdminMonitoringLocalGateway implements AdminMonitoringGateway { private events: AdminMonitoringEvent[] | null = null; loadEvents(filters: AdminMonitoringEventFilters): Observable { diff --git a/src/app/features/admin/products/services/admin-products-local.gateway.ts b/src/app/features/admin/products/services/admin-products-local.gateway.ts index 531f5a7..93359ff 100644 --- a/src/app/features/admin/products/services/admin-products-local.gateway.ts +++ b/src/app/features/admin/products/services/admin-products-local.gateway.ts @@ -113,10 +113,18 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { return; } - const products = await new Promise(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value))); + // Resolve with an empty list on transport failure so callers see an empty + // state instead of a promise that never settles (which froze every admin + // list in a permanent loading state when the API was unreachable). + const products = await new Promise(resolve => + this.backofficeData.loadProducts().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); const categories = await new Promise(resolve => this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: false }) - .subscribe(value => resolve(value.map(category => ({ id: category.id, title: category.title })))) + .subscribe({ + next: value => resolve(value.map(category => ({ id: category.id, title: category.title }))), + error: () => resolve([]), + }) ); this.productsCache = products.map((product, index) => this.toAdminProduct(product, categories[index % Math.max(1, categories.length)]?.id ?? 'cat-001')); this.categoriesCache = categories; diff --git a/src/app/features/admin/shell/admin-layout.component.ts b/src/app/features/admin/shell/admin-layout.component.ts index b5b1cce..f84fd86 100644 --- a/src/app/features/admin/shell/admin-layout.component.ts +++ b/src/app/features/admin/shell/admin-layout.component.ts @@ -130,7 +130,10 @@ export class AdminLayoutComponent { while (leaf.firstChild) { leaf = leaf.firstChild; } - const data = leaf.snapshot.data as { + // Route metadata is optional: during the initial navigation (or for routes + // registered without `data`) the leaf snapshot may not exist yet. Fall back + // to dashboard defaults instead of crashing the whole admin shell. + const data = (leaf.snapshot?.data ?? {}) as { titleKey?: string; descriptionKey?: string; breadcrumb?: AdminBreadcrumbEntry[]; diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts index 8111e2a..affb40a 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.ts +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -934,7 +934,9 @@ export class CatalogContainerComponent { return numeric; } - const categories = await new Promise(resolve => this.categoryFacade.getAllCategories().subscribe(value => resolve(value))); + const categories = await new Promise(resolve => + this.categoryFacade.getAllCategories().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); const normalized = categoryToken.trim().toLowerCase(); const matched = categories.find(category => category.title.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-') === normalized); return matched?.id ?? null;