refactor(core): prepare frontend for backend integration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

- 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
This commit is contained in:
sdarbinyan
2026-07-19 07:53:47 +04:00
parent e2ec8dc632
commit 16dec127c9
8 changed files with 73 additions and 10 deletions

View File

@@ -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<ProductCardConfig[]> | null = null;
private categories$: Observable<CategoryCardConfig[]> | null = null;
loadProducts(): Observable<ProductCardConfig[]> {
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<CategoryCardConfig[]> {
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$;
}
}

View File

@@ -79,7 +79,10 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway {
return;
}
const categories = await new Promise<CategoryCardConfig[]>(resolve => this.backofficeData.loadCategories().subscribe(value => resolve(value)));
// Empty list on transport failure - never leave the promise unsettled.
const categories = await new Promise<CategoryCardConfig[]>(resolve =>
this.backofficeData.loadCategories().subscribe({ next: value => resolve(value), error: () => resolve([]) })
);
this.cache = categories.map(category => this.toAdminCategory(category));
}

View File

@@ -129,7 +129,10 @@ export class AdminModerationLocalGateway implements AdminModerationGateway {
if (this.cache && this.reportsCache) {
return;
}
this.products = await new Promise<ProductCardConfig[]>(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value)));
// Empty list on transport failure - never leave the promise unsettled.
this.products = await new Promise<ProductCardConfig[]>(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();
}

View File

@@ -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<AdminMonitoringEvent[]>;
loadQueues(): Observable<AdminQueue[]>;
loadWebhooks(): Observable<AdminWebhookDelivery[]>;
}

View File

@@ -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<AdminMonitoringEvent[]> {

View File

@@ -113,10 +113,18 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
return;
}
const products = await new Promise<ProductCardConfig[]>(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<ProductCardConfig[]>(resolve =>
this.backofficeData.loadProducts().subscribe({ next: value => resolve(value), error: () => resolve([]) })
);
const categories = await new Promise<AdminProductCategoryOption[]>(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;

View File

@@ -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[];

View File

@@ -934,7 +934,9 @@ export class CatalogContainerComponent {
return numeric;
}
const categories = await new Promise<Category[]>(resolve => this.categoryFacade.getAllCategories().subscribe(value => resolve(value)));
const categories = await new Promise<Category[]>(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;