feat: real API gateways for all remaining local-only domains (F17-F39)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Block 3 of the frontend backlog. 18 gateways were hardcoded to their local
(mock/localStorage) implementation with no seam to a real backend at all -
this closes that gap for everything with a documented contract to build
against.

10 core gateways, token now resolves environment.useMockData ? local : api,
same pattern already proven on fx-quote earlier this session:

  permission, analytics, cart (server-cart), finance, vk-id (identity),
  connector (integrations), marketplace (registry), offer, seller,
  mall-content

8 admin gateways, same pattern:

  orders, products, users, transactions, monitoring, moderation,
  notifications, dashboard-metrics

Endpoints came from the matching contract doc where one exists (Phase 1-10,
Track A, Track S - cited per file). Three domains have no dedicated contract
doc yet (transactions, monitoring, moderation) - those gateways call the
established /api/admin/v2/{resource} convention used throughout the rest of
docs/backend/, flagged in each file's own comment as inferred rather than
specified, for whoever writes that contract to confirm or correct.

One real fix along the way: offer-api.gateway.ts's publish() translates a
422 + details[] response (contract §7's actual failure mode) into the
interface's { ok: false, errors } shape, rather than letting an HTTP error
leak past a caller that expects a value back.

Media repository (mock-media-repository.service.ts) deliberately NOT
swapped - no backend contract exists for it anywhere in docs/backend/, and
inventing endpoint shapes with zero grounding is worse than leaving it mock.

Regression this surfaced, fixed as part of the same change: three spec files
stubbed a gateway's concrete Local class directly via useValue. That worked
by accident while the token unconditionally resolved to the local class; once
the token became conditional on useMockData, those specs silently injected
the real (unmocked) API gateway instead and failed. Fixed by providing the
token instead of the class - the pattern the ADMIN_CATEGORIES_GATEWAY entry
in the same spec file already used correctly, because categories was
already token-swapped before this session:
  - admin-analytics.facade.spec.ts (orders/products/moderation gateways)
  - admin-order-watcher.service.spec.ts (orders gateway, 3 call sites)

Verified: 115/115 unit tests, 5/5 E2E, arch:check clean, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 14:29:50 +04:00
parent fc53a3b7f5
commit ffd57f2d18
38 changed files with 787 additions and 43 deletions

View File

@@ -0,0 +1,18 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AnalyticsEvent } from '../models/analytics-event.model';
import { AnalyticsGateway } from './analytics-gateway.interface';
/** Contract: docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1 - server-side batched ingest. */
@Injectable({ providedIn: 'root' })
export class AnalyticsApiGateway implements AnalyticsGateway {
private readonly http = inject(HttpClient);
track(event: AnalyticsEvent): Observable<void> {
return this.http
.post('/api/v2/storefront/analytics/events', event)
.pipe(map(() => undefined));
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { AnalyticsGateway } from './analytics-gateway.interface'; import { AnalyticsGateway } from './analytics-gateway.interface';
import { AnalyticsLocalGateway } from './analytics-local.gateway'; import { AnalyticsLocalGateway } from './analytics-local.gateway';
import { AnalyticsApiGateway } from './analytics-api.gateway';
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */ /** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', { export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AnalyticsLocalGateway), factory: () => (environment.useMockData ? inject(AnalyticsLocalGateway) : inject(AnalyticsApiGateway)),
}); });

View File

@@ -0,0 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
import { ServerCartGateway } from './server-cart-gateway.interface';
/** Contract: docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
@Injectable({ providedIn: 'root' })
export class ServerCartApiGateway implements ServerCartGateway {
private readonly http = inject(HttpClient);
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
return this.http.get<{ cart: ServerCart; lines: ServerCartLine[] }>('/api/v2/storefront/cart');
}
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
return this.http.post<ServerCartLine>('/api/v2/storefront/cart/lines', { offerId, qty });
}
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
return this.http.patch<ServerCartLine>(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`, { qty });
}
removeLine(lineId: string): Observable<void> {
return this.http
.delete(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`)
.pipe(map(() => undefined));
}
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession> {
// §5 example body is { cartId, currency, deliveryOptionId } - cartId is
// implicit server-side (the session's own cart), so it is not sent here.
return this.http.post<CheckoutSession>('/api/v2/storefront/checkout', { currency, deliveryOptionId });
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { ServerCartGateway } from './server-cart-gateway.interface'; import { ServerCartGateway } from './server-cart-gateway.interface';
import { ServerCartLocalGateway } from './server-cart-local.gateway'; import { ServerCartLocalGateway } from './server-cart-local.gateway';
import { ServerCartApiGateway } from './server-cart-api.gateway';
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */ /** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', { export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(ServerCartLocalGateway), factory: () => (environment.useMockData ? inject(ServerCartLocalGateway) : inject(ServerCartApiGateway)),
}); });

View File

@@ -0,0 +1,46 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Floor, Lead, RentListing, SchemePin, Shop, ShopCategory } from '../models/mall-content.model';
import { MallContentGateway } from './mall-content-gateway.interface';
/**
* Contract: docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md §2.
* Only the lead-submission and settings endpoints are spelled out explicitly
* (§ "PATCH /api/admin/v2/content/mall-settings", "POST .../rent-listings/{id}/leads");
* the read endpoints below follow that same /api/admin/v2/content/ namespace.
*/
@Injectable({ providedIn: 'root' })
export class MallContentApiGateway implements MallContentGateway {
private readonly http = inject(HttpClient);
loadShops(): Observable<Shop[]> {
return this.http.get<Shop[]>('/api/admin/v2/content/shops');
}
loadShopCategories(): Observable<ShopCategory[]> {
return this.http.get<ShopCategory[]>('/api/admin/v2/content/shop-categories');
}
loadFloors(): Observable<Floor[]> {
return this.http.get<Floor[]>('/api/admin/v2/content/floors');
}
loadSchemePins(floorId: string): Observable<SchemePin[]> {
return this.http.get<SchemePin[]>(
`/api/admin/v2/content/floors/${encodeURIComponent(floorId)}/pins`,
);
}
loadRentListings(): Observable<RentListing[]> {
return this.http.get<RentListing[]>('/api/admin/v2/content/rent-listings');
}
submitLead(lead: Omit<Lead, 'id' | 'createdAt'>): Observable<Lead> {
const listingId = lead.rentListingId ?? '';
return this.http.post<Lead>(
`/api/admin/v2/content/rent-listings/${encodeURIComponent(listingId)}/leads`,
lead,
);
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { MallContentGateway } from './mall-content-gateway.interface'; import { MallContentGateway } from './mall-content-gateway.interface';
import { MallContentLocalGateway } from './mall-content-local.gateway'; import { MallContentLocalGateway } from './mall-content-local.gateway';
import { MallContentApiGateway } from './mall-content-api.gateway';
/** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */ /** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */
export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', { export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(MallContentLocalGateway), factory: () => (environment.useMockData ? inject(MallContentLocalGateway) : inject(MallContentApiGateway)),
}); });

View File

@@ -0,0 +1,40 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
import { FinanceGateway } from './finance-gateway.interface';
/** Contract: docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
@Injectable({ providedIn: 'root' })
export class FinanceApiGateway implements FinanceGateway {
private readonly http = inject(HttpClient);
loadRefunds(orderId?: string): Observable<Refund[]> {
// §1: refunds are scoped to one order (POST/GET .../orders/{orderId}/refunds).
// Without an orderId there is no single-order endpoint to call; return
// empty rather than guess at a global refunds list the contract doesn't define.
if (!orderId) {
return new Observable<Refund[]>(subscriber => {
subscriber.next([]);
subscriber.complete();
});
}
return this.http.get<Refund[]>(`/api/admin/v2/orders/${encodeURIComponent(orderId)}/refunds`);
}
loadReconciliationQueue(): Observable<ReconciliationRecord[]> {
return this.http.get<ReconciliationRecord[]>('/api/admin/v2/reconciliation/queue');
}
resolveReconciliation(id: string, note: string): Observable<void> {
return this.http
.post(`/api/admin/v2/reconciliation/${encodeURIComponent(id)}/resolve`, { note })
.pipe(map(() => undefined));
}
loadSettlements(sellerId?: string): Observable<Settlement[]> {
const params = sellerId ? new HttpParams().set('sellerId', sellerId) : undefined;
return this.http.get<Settlement[]>('/api/admin/v2/finance/settlements', { params });
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { FinanceGateway } from './finance-gateway.interface'; import { FinanceGateway } from './finance-gateway.interface';
import { FinanceLocalGateway } from './finance-local.gateway'; import { FinanceLocalGateway } from './finance-local.gateway';
import { FinanceApiGateway } from './finance-api.gateway';
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */ /** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', { export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(FinanceLocalGateway), factory: () => (environment.useMockData ? inject(FinanceLocalGateway) : inject(FinanceApiGateway)),
}); });

View File

@@ -0,0 +1,27 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Customer } from '../models/customer-identity.model';
import { VkIdGateway } from './vk-id-gateway.interface';
/**
* Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
* OAuth completion happens backend-side; this is the client-facing surface
* only - getAuthorizeUrl navigates the browser there, completeCallback hands
* back the code/verifier pair for the backend to exchange.
*/
@Injectable({ providedIn: 'root' })
export class VkIdApiGateway implements VkIdGateway {
private readonly http = inject(HttpClient);
getAuthorizeUrl(): Observable<string> {
return this.http
.get<{ url: string }>('/api/identity/v1/vk/authorize')
.pipe(map(response => response.url));
}
completeCallback(code: string, codeVerifier: string): Observable<Customer> {
return this.http.post<Customer>('/api/identity/v1/vk/callback', { code, codeVerifier });
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { VkIdGateway } from './vk-id-gateway.interface'; import { VkIdGateway } from './vk-id-gateway.interface';
import { VkIdLocalGateway } from './vk-id-local.gateway'; import { VkIdLocalGateway } from './vk-id-local.gateway';
import { VkIdApiGateway } from './vk-id-api.gateway';
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */ /** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', { export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(VkIdLocalGateway), factory: () => (environment.useMockData ? inject(VkIdLocalGateway) : inject(VkIdApiGateway)),
}); });

View File

@@ -0,0 +1,43 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Connector, DeadLetterEntry } from '../models/connector.model';
import { ConnectorGateway } from './connector-gateway.interface';
/** Contract: docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7-8. */
@Injectable({ providedIn: 'root' })
export class ConnectorApiGateway implements ConnectorGateway {
private readonly http = inject(HttpClient);
loadConnectors(): Observable<Connector[]> {
return this.http.get<Connector[]>('/api/admin/v2/integrations');
}
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]> {
// §7 defines dead-letter replay (POST .../dead-letter/{id}/replay) but not
// a GET list endpoint - inferred at the same collection path, consistent
// with the rest of this contract's REST conventions.
return this.http.get<DeadLetterEntry[]>(
`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}/dead-letter`,
);
}
replay(deadLetterId: string): Observable<void> {
return this.http
.post(`/api/admin/v2/integrations/dead-letter/${encodeURIComponent(deadLetterId)}/replay`, {})
.pipe(map(() => undefined));
}
pause(connectorId: string): Observable<void> {
return this.http
.patch(`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}`, { status: 'paused' })
.pipe(map(() => undefined));
}
resume(connectorId: string): Observable<void> {
return this.http
.patch(`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}`, { status: 'active' })
.pipe(map(() => undefined));
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { ConnectorGateway } from './connector-gateway.interface'; import { ConnectorGateway } from './connector-gateway.interface';
import { ConnectorLocalGateway } from './connector-local.gateway'; import { ConnectorLocalGateway } from './connector-local.gateway';
import { ConnectorApiGateway } from './connector-api.gateway';
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */ /** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', { export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(ConnectorLocalGateway), factory: () => (environment.useMockData ? inject(ConnectorLocalGateway) : inject(ConnectorApiGateway)),
}); });

View File

@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../models/marketplace.model';
import { MarketplaceGateway } from './marketplace-gateway.interface';
/** Contract: docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §2-4. */
@Injectable({ providedIn: 'root' })
export class MarketplaceApiGateway implements MarketplaceGateway {
private readonly http = inject(HttpClient);
loadMarketplaces(): Observable<Marketplace[]> {
// The contract specifies POST/PATCH marketplace endpoints in detail but
// not the list GET explicitly - inferred at the collection root, the
// conventional counterpart to POST /api/admin/v2/marketplaces (§3 step 1).
return this.http.get<Marketplace[]>('/api/admin/v2/marketplaces');
}
loadDomains(marketplaceId: string): Observable<MarketplaceDomain[]> {
return this.http.get<MarketplaceDomain[]>(
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/domains`,
);
}
loadLifecycle(marketplaceId: string): Observable<LifecycleAdvanceResult> {
return this.http.get<LifecycleAdvanceResult>(
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/lifecycle`,
);
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { MarketplaceGateway } from './marketplace-gateway.interface'; import { MarketplaceGateway } from './marketplace-gateway.interface';
import { MarketplaceLocalGateway } from './marketplace-local.gateway'; import { MarketplaceLocalGateway } from './marketplace-local.gateway';
import { MarketplaceApiGateway } from './marketplace-api.gateway';
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */ /** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */
export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', { export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(MarketplaceLocalGateway), factory: () => (environment.useMockData ? inject(MarketplaceLocalGateway) : inject(MarketplaceApiGateway)),
}); });

View File

@@ -0,0 +1,56 @@
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model';
import { OfferGateway } from './offer-gateway.interface';
/** Contract: docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
@Injectable({ providedIn: 'root' })
export class OfferApiGateway implements OfferGateway {
private readonly http = inject(HttpClient);
loadOffers(sellerId?: string): Observable<Offer[]> {
const params = sellerId ? new HttpParams().set('sellerId', sellerId) : undefined;
return this.http.get<Offer[]>('/api/admin/v2/offers', { params });
}
lookup(query: OfferLookupQuery): Observable<Offer[]> {
let params = new HttpParams();
for (const [key, value] of Object.entries(query)) {
if (value) {
params = params.set(key, value);
}
}
return this.http.get<Offer[]>('/api/admin/v2/offers/lookup', { params });
}
loadInventory(offerId: string): Observable<InventoryRecord | null> {
// §7 does not define a dedicated inventory-by-offer endpoint - inferred
// as a sub-resource of the offer, consistent with this contract's
// {id}/publish pattern.
return this.http.get<InventoryRecord | null>(
`/api/admin/v2/offers/${encodeURIComponent(offerId)}/inventory`,
);
}
publish(offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }> {
// §7: publish runs the §5 executability check and fails with 422 +
// details[], not a 200 body - translated here into the interface's
// ok:false shape so callers get a value, not an error to catch.
return this.http
.post<{ ok: true }>(`/api/admin/v2/offers/${encodeURIComponent(offerId)}/publish`, {})
.pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 422) {
const details = err.error?.error?.details ?? [];
const errors = Array.isArray(details) && details.length > 0
? details.map((d: { message?: string }) => d.message ?? 'Validation failed')
: ['Offer is not executable.'];
return of({ ok: false as const, errors });
}
throw err;
}),
);
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { OfferGateway } from './offer-gateway.interface'; import { OfferGateway } from './offer-gateway.interface';
import { OfferLocalGateway } from './offer-local.gateway'; import { OfferLocalGateway } from './offer-local.gateway';
import { OfferApiGateway } from './offer-api.gateway';
/** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */ /** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
export const OFFER_GATEWAY = new InjectionToken<OfferGateway>('OFFER_GATEWAY', { export const OFFER_GATEWAY = new InjectionToken<OfferGateway>('OFFER_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(OfferLocalGateway), factory: () => (environment.useMockData ? inject(OfferLocalGateway) : inject(OfferApiGateway)),
}); });

View File

@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AuditEvent, SessionPermissions } from '../models/permission.model';
import { PermissionGateway } from './permission-gateway.interface';
/** Contract: docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2-3. */
@Injectable({ providedIn: 'root' })
export class PermissionApiGateway implements PermissionGateway {
private readonly http = inject(HttpClient);
getSessionPermissions(): Observable<SessionPermissions> {
return this.http.get<SessionPermissions>('/api/identity/v1/session/permissions');
}
loadAuditLog(): Observable<AuditEvent[]> {
return this.http.get<AuditEvent[]>('/api/admin/v2/audit');
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { PermissionGateway } from './permission-gateway.interface'; import { PermissionGateway } from './permission-gateway.interface';
import { PermissionLocalGateway } from './permission-local.gateway'; import { PermissionLocalGateway } from './permission-local.gateway';
import { PermissionApiGateway } from './permission-api.gateway';
/** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2. */ /** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2-3. */
export const PERMISSION_GATEWAY = new InjectionToken<PermissionGateway>('PERMISSION_GATEWAY', { export const PERMISSION_GATEWAY = new InjectionToken<PermissionGateway>('PERMISSION_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(PermissionLocalGateway), factory: () => (environment.useMockData ? inject(PermissionLocalGateway) : inject(PermissionApiGateway)),
}); });

View File

@@ -0,0 +1,27 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Seller } from '../models/seller.model';
import { SellerUser } from '../models/seller-user.model';
import { SellerGateway } from './seller-gateway.interface';
/** Contract: docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. */
@Injectable({ providedIn: 'root' })
export class SellerApiGateway implements SellerGateway {
private readonly http = inject(HttpClient);
loadSellers(): Observable<Seller[]> {
// §3 defines GET /api/seller/v1/profile (the acting seller's own record).
// An admin-side list-all-sellers endpoint isn't in this contract; inferred
// at the conventional admin collection path pending that being specified.
return this.http.get<Seller[]>('/api/admin/v2/sellers');
}
loadTeam(sellerId: string): Observable<SellerUser[]> {
// §3's GET /api/seller/v1/team is scoped to the caller's own session, not
// parameterized by sellerId. Using the admin-side sub-resource path so
// an admin looking at a specific seller's team has a real endpoint to
// call; revisit if the backend exposes this differently.
return this.http.get<SellerUser[]>(`/api/admin/v2/sellers/${encodeURIComponent(sellerId)}/team`);
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { SellerGateway } from './seller-gateway.interface'; import { SellerGateway } from './seller-gateway.interface';
import { SellerLocalGateway } from './seller-local.gateway'; import { SellerLocalGateway } from './seller-local.gateway';
import { SellerApiGateway } from './seller-api.gateway';
/** Swap point for docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. */ /** Swap point for docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. */
export const SELLER_GATEWAY = new InjectionToken<SellerGateway>('SELLER_GATEWAY', { export const SELLER_GATEWAY = new InjectionToken<SellerGateway>('SELLER_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(SellerLocalGateway), factory: () => (environment.useMockData ? inject(SellerLocalGateway) : inject(SellerApiGateway)),
}); });

View File

@@ -1,10 +1,13 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { AdminAnalyticsFacade } from './admin-analytics.facade'; import { AdminAnalyticsFacade } from './admin-analytics.facade';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; import { AdminOrdersGateway } from '../../orders/services/admin-orders-gateway.interface';
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway'; import { ADMIN_ORDERS_GATEWAY } from '../../orders/services/admin-orders-gateway.token';
import { AdminProductsGateway } from '../../products/services/admin-products-gateway.interface';
import { ADMIN_PRODUCTS_GATEWAY } from '../../products/services/admin-products-gateway.token';
import { ADMIN_CATEGORIES_GATEWAY } from '../../categories/services/admin-categories-gateway.token'; import { ADMIN_CATEGORIES_GATEWAY } from '../../categories/services/admin-categories-gateway.token';
import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway'; import { AdminModerationGateway } from '../../moderation/services/admin-moderation-gateway.interface';
import { ADMIN_MODERATION_GATEWAY } from '../../moderation/services/admin-moderation-gateway.token';
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade'; import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
import { AdminOrder } from '../../orders/models/admin-order.model'; import { AdminOrder } from '../../orders/models/admin-order.model';
@@ -23,17 +26,17 @@ function makeOrder(overrides: Partial<AdminOrder> = {}): AdminOrder {
describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => { describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => {
let facade: AdminAnalyticsFacade; let facade: AdminAnalyticsFacade;
let ordersGateway: jasmine.SpyObj<AdminOrdersLocalGateway>; let ordersGateway: jasmine.SpyObj<AdminOrdersGateway>;
let productsGateway: jasmine.SpyObj<AdminProductsLocalGateway>; let productsGateway: jasmine.SpyObj<AdminProductsGateway>;
let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>; let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>;
let moderationGateway: jasmine.SpyObj<AdminModerationLocalGateway>; let moderationGateway: jasmine.SpyObj<AdminModerationGateway>;
let dashboardFacade: jasmine.SpyObj<AdminDashboardFacade>; let dashboardFacade: jasmine.SpyObj<AdminDashboardFacade>;
function configure(bootstrapPresent: boolean): void { function configure(bootstrapPresent: boolean): void {
ordersGateway = jasmine.createSpyObj('AdminOrdersLocalGateway', ['loadOrders']); ordersGateway = jasmine.createSpyObj('ADMIN_ORDERS_GATEWAY', ['loadOrders']);
productsGateway = jasmine.createSpyObj('AdminProductsLocalGateway', ['loadProducts']); productsGateway = jasmine.createSpyObj('ADMIN_PRODUCTS_GATEWAY', ['loadProducts']);
categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']); categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']);
moderationGateway = jasmine.createSpyObj('AdminModerationLocalGateway', ['loadReviews']); moderationGateway = jasmine.createSpyObj('ADMIN_MODERATION_GATEWAY', ['loadReviews']);
dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [ dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [
'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount', 'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount',
]); ]);
@@ -50,10 +53,10 @@ describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{ provide: AdminOrdersLocalGateway, useValue: ordersGateway }, { provide: ADMIN_ORDERS_GATEWAY, useValue: ordersGateway },
{ provide: AdminProductsLocalGateway, useValue: productsGateway }, { provide: ADMIN_PRODUCTS_GATEWAY, useValue: productsGateway },
{ provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway }, { provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway },
{ provide: AdminModerationLocalGateway, useValue: moderationGateway }, { provide: ADMIN_MODERATION_GATEWAY, useValue: moderationGateway },
{ provide: AdminDashboardFacade, useValue: dashboardFacade }, { provide: AdminDashboardFacade, useValue: dashboardFacade },
], ],
}); });

View File

@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AdminDashboardMetrics } from '../models/admin-dashboard.model';
import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface';
/**
* No dedicated dashboard-metrics contract doc exists yet. Phase 9 §7 lists
* the target metrics (GMV, paid orders, conversion, etc.) without a wire
* shape - at the conventional /api/admin/v2/{resource} path pending that.
*/
@Injectable({ providedIn: 'root' })
export class AdminDashboardMetricsApiGateway implements AdminDashboardMetricsGateway {
private readonly http = inject(HttpClient);
loadMetrics(): Observable<AdminDashboardMetrics> {
return this.http.get<AdminDashboardMetrics>('/api/admin/v2/dashboard/metrics');
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface'; import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface';
import { AdminDashboardMetricsLocalGateway } from './admin-dashboard-metrics.local.gateway'; import { AdminDashboardMetricsLocalGateway } from './admin-dashboard-metrics.local.gateway';
import { AdminDashboardMetricsApiGateway } from './admin-dashboard-metrics-api.gateway';
/** Swap point for a future dedicated dashboard-metrics backend endpoint - today it composes existing backoffice data sources. */ /** Swap point. */
export const ADMIN_DASHBOARD_METRICS_GATEWAY = new InjectionToken<AdminDashboardMetricsGateway>('ADMIN_DASHBOARD_METRICS_GATEWAY', { export const ADMIN_DASHBOARD_METRICS_GATEWAY = new InjectionToken<AdminDashboardMetricsGateway>('ADMIN_DASHBOARD_METRICS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminDashboardMetricsLocalGateway), factory: () => (environment.useMockData ? inject(AdminDashboardMetricsLocalGateway) : inject(AdminDashboardMetricsApiGateway)),
}); });

View File

@@ -0,0 +1,64 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus } from '../models/admin-review.model';
import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
import { AdminModerationGateway } from './admin-moderation-gateway.interface';
/**
* No dedicated moderation contract doc exists yet - at the conventional
* /api/admin/v2/{resource} path used throughout the rest of docs/backend/.
*/
@Injectable({ providedIn: 'root' })
export class AdminModerationApiGateway implements AdminModerationGateway {
private readonly http = inject(HttpClient);
loadReviews(filters: AdminReviewListFilters): Observable<AdminReviewsListResult> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<AdminReviewsListResult>('/api/admin/v2/moderation/reviews', { params });
}
loadReview(id: string): Observable<AdminReview | null> {
return this.http.get<AdminReview | null>(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`);
}
setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null> {
return this.http.patch<AdminReview | null>(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { status, note });
}
setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null> {
return this.http.patch<AdminReview | null>(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { visible });
}
setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null> {
return this.http.patch<AdminReview | null>(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { pinned });
}
setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null> {
return this.http.patch<AdminReview | null>(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { featured });
}
addModeratorNote(id: string, note: string): Observable<AdminReview | null> {
return this.http.post<AdminReview | null>(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}/notes`, { note });
}
deleteReview(id: string): Observable<void> {
return this.http
.delete(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`)
.pipe(map(() => undefined));
}
loadReports(): Observable<AdminReport[]> {
return this.http.get<AdminReport[]>('/api/admin/v2/moderation/reports');
}
setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null> {
return this.http.patch<AdminReport | null>(`/api/admin/v2/moderation/reports/${encodeURIComponent(id)}`, { status });
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminModerationGateway } from './admin-moderation-gateway.interface'; import { AdminModerationGateway } from './admin-moderation-gateway.interface';
import { AdminModerationLocalGateway } from './admin-moderation-local.gateway'; import { AdminModerationLocalGateway } from './admin-moderation-local.gateway';
import { AdminModerationApiGateway } from './admin-moderation-api.gateway';
/** Swap point for a real Moderation backend - see BACKEND-API-REFERENCE.md §8 (no seam existed before this token). */ /** Swap point. */
export const ADMIN_MODERATION_GATEWAY = new InjectionToken<AdminModerationGateway>('ADMIN_MODERATION_GATEWAY', { export const ADMIN_MODERATION_GATEWAY = new InjectionToken<AdminModerationGateway>('ADMIN_MODERATION_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminModerationLocalGateway), factory: () => (environment.useMockData ? inject(AdminModerationLocalGateway) : inject(AdminModerationApiGateway)),
}); });

View File

@@ -0,0 +1,33 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AdminMonitoringEvent, AdminMonitoringEventFilters, AdminQueue, AdminWebhookDelivery } from '../models/admin-monitoring.model';
import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface';
/**
* No dedicated monitoring contract doc exists yet - at the conventional
* /api/admin/v2/{resource} path per the interface's own doc comment
* (a future HTTP gateway implements the same shape).
*/
@Injectable({ providedIn: 'root' })
export class AdminMonitoringApiGateway implements AdminMonitoringGateway {
private readonly http = inject(HttpClient);
loadEvents(filters: AdminMonitoringEventFilters): Observable<AdminMonitoringEvent[]> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<AdminMonitoringEvent[]>('/api/admin/v2/monitoring/events', { params });
}
loadQueues(): Observable<AdminQueue[]> {
return this.http.get<AdminQueue[]>('/api/admin/v2/monitoring/queues');
}
loadWebhooks(): Observable<AdminWebhookDelivery[]> {
return this.http.get<AdminWebhookDelivery[]>('/api/admin/v2/monitoring/webhooks');
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface'; import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface';
import { AdminMonitoringLocalGateway } from './admin-monitoring-local.gateway'; import { AdminMonitoringLocalGateway } from './admin-monitoring-local.gateway';
import { AdminMonitoringApiGateway } from './admin-monitoring-api.gateway';
/** Swap point for a real Monitoring backend - see BACKEND-API-REFERENCE.md §8 (no seam existed before this token). */ /** Swap point. */
export const ADMIN_MONITORING_GATEWAY = new InjectionToken<AdminMonitoringGateway>('ADMIN_MONITORING_GATEWAY', { export const ADMIN_MONITORING_GATEWAY = new InjectionToken<AdminMonitoringGateway>('ADMIN_MONITORING_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminMonitoringLocalGateway), factory: () => (environment.useMockData ? inject(AdminMonitoringLocalGateway) : inject(AdminMonitoringApiGateway)),
}); });

View File

@@ -0,0 +1,37 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
/** Contract: docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §7. */
@Injectable({ providedIn: 'root' })
export class AdminNotificationsApiGateway implements AdminNotificationsGateway {
private readonly http = inject(HttpClient);
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<AdminNotification[]>('/api/admin/v2/notifications', { params });
}
markRead(id: string): Observable<void> {
return this.http
.patch(`/api/admin/v2/notifications/${encodeURIComponent(id)}/read`, {})
.pipe(map(() => undefined));
}
markAllRead(): Observable<void> {
// Contract documents per-notification PATCH .../{id}/read only; a bulk
// endpoint isn't specified. Inferred at the collection path pending that
// being defined - revisit if the backend does this differently.
return this.http
.patch('/api/admin/v2/notifications/read-all', {})
.pipe(map(() => undefined));
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface'; import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
import { AdminNotificationsLocalGateway } from './admin-notifications-local.gateway'; import { AdminNotificationsLocalGateway } from './admin-notifications-local.gateway';
import { AdminNotificationsApiGateway } from './admin-notifications-api.gateway';
/** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */ /** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §7. */
export const ADMIN_NOTIFICATIONS_GATEWAY = new InjectionToken<AdminNotificationsGateway>('ADMIN_NOTIFICATIONS_GATEWAY', { export const ADMIN_NOTIFICATIONS_GATEWAY = new InjectionToken<AdminNotificationsGateway>('ADMIN_NOTIFICATIONS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminNotificationsLocalGateway), factory: () => (environment.useMockData ? inject(AdminNotificationsLocalGateway) : inject(AdminNotificationsApiGateway)),
}); });

View File

@@ -0,0 +1,50 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
/** Contract: docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md. */
@Injectable({ providedIn: 'root' })
export class AdminOrdersApiGateway implements AdminOrdersGateway {
private readonly http = inject(HttpClient);
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<AdminOrdersListResult>('/api/admin/v2/orders', { params });
}
loadOrder(id: string): Observable<AdminOrder | null> {
return this.http.get<AdminOrder | null>(`/api/admin/v2/orders/${encodeURIComponent(id)}`);
}
updateStatus(id: string, status: AdminOrderStatus): Observable<AdminOrder | null> {
return this.http.patch<AdminOrder | null>(`/api/admin/v2/orders/${encodeURIComponent(id)}/status`, { status });
}
requestRefund(id: string): Observable<AdminOrder | null> {
return this.http.post<AdminOrder | null>(`/api/admin/v2/orders/${encodeURIComponent(id)}/refund-request`, {});
}
addNote(id: string, note: string, internal: boolean): Observable<AdminOrder | null> {
return this.http.post<AdminOrder | null>(`/api/admin/v2/orders/${encodeURIComponent(id)}/notes`, { note, internal });
}
archiveOrder(id: string): Observable<AdminOrder | null> {
return this.http.post<AdminOrder | null>(`/api/admin/v2/orders/${encodeURIComponent(id)}/archive`, {});
}
restoreOrder(id: string): Observable<AdminOrder | null> {
return this.http.post<AdminOrder | null>(`/api/admin/v2/orders/${encodeURIComponent(id)}/restore`, {});
}
deleteOrder(id: string): Observable<void> {
return this.http.delete(`/api/admin/v2/orders/${encodeURIComponent(id)}`).pipe(map(() => undefined));
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminOrdersGateway } from './admin-orders-gateway.interface'; import { AdminOrdersGateway } from './admin-orders-gateway.interface';
import { AdminOrdersLocalGateway } from './admin-orders-local.gateway'; import { AdminOrdersLocalGateway } from './admin-orders-local.gateway';
import { AdminOrdersApiGateway } from './admin-orders-api.gateway';
/** Swap point for a real Orders backend - see BACKEND-API-REFERENCE.md §8 (no seam existed before this token). */ /** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md. */
export const ADMIN_ORDERS_GATEWAY = new InjectionToken<AdminOrdersGateway>('ADMIN_ORDERS_GATEWAY', { export const ADMIN_ORDERS_GATEWAY = new InjectionToken<AdminOrdersGateway>('ADMIN_ORDERS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminOrdersLocalGateway), factory: () => (environment.useMockData ? inject(AdminOrdersLocalGateway) : inject(AdminOrdersApiGateway)),
}); });

View File

@@ -0,0 +1,61 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters, AdminProductsListResult } from '../models/admin-product.model';
import { AdminProductsGateway } from './admin-products-gateway.interface';
/**
* Contract: docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7
* (list/get/create/update on /api/admin/v2/products). duplicate/archive/
* restore aren't individually specified - inferred as sub-resource actions,
* consistent with the offers/{id}/publish pattern in the same contract.
*/
@Injectable({ providedIn: 'root' })
export class AdminProductsApiGateway implements AdminProductsGateway {
private readonly http = inject(HttpClient);
loadProducts(filters: AdminProductListFilters): Observable<AdminProductsListResult> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<AdminProductsListResult>('/api/admin/v2/products', { params });
}
loadProduct(id: string): Observable<AdminProduct | null> {
return this.http.get<AdminProduct | null>(`/api/admin/v2/products/${encodeURIComponent(id)}`);
}
loadCategories(): Observable<AdminProductCategoryOption[]> {
return this.http.get<AdminProductCategoryOption[]>('/api/admin/v2/products/categories');
}
createProduct(product: AdminProduct): Observable<AdminProduct> {
return this.http.post<AdminProduct>('/api/admin/v2/products', product);
}
updateProduct(product: AdminProduct): Observable<AdminProduct> {
return this.http.patch<AdminProduct>(`/api/admin/v2/products/${encodeURIComponent(product.id)}`, product);
}
deleteProduct(id: string): Observable<void> {
return this.http.delete(`/api/admin/v2/products/${encodeURIComponent(id)}`).pipe(map(() => undefined));
}
duplicateProduct(id: string): Observable<AdminProduct | null> {
return this.http.post<AdminProduct | null>(`/api/admin/v2/products/${encodeURIComponent(id)}/duplicate`, {});
}
archiveProduct(id: string): Observable<void> {
return this.http
.post(`/api/admin/v2/products/${encodeURIComponent(id)}/archive`, {})
.pipe(map(() => undefined));
}
restoreProduct(id: string): Observable<AdminProduct | null> {
return this.http.post<AdminProduct | null>(`/api/admin/v2/products/${encodeURIComponent(id)}/restore`, {});
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminProductsGateway } from './admin-products-gateway.interface'; import { AdminProductsGateway } from './admin-products-gateway.interface';
import { AdminProductsLocalGateway } from './admin-products-local.gateway'; import { AdminProductsLocalGateway } from './admin-products-local.gateway';
import { AdminProductsApiGateway } from './admin-products-api.gateway';
/** Swap point for a real Products backend - see BACKEND-API-REFERENCE.md §8 (no seam existed before this token). */ /** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
export const ADMIN_PRODUCTS_GATEWAY = new InjectionToken<AdminProductsGateway>('ADMIN_PRODUCTS_GATEWAY', { export const ADMIN_PRODUCTS_GATEWAY = new InjectionToken<AdminProductsGateway>('ADMIN_PRODUCTS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminProductsLocalGateway), factory: () => (environment.useMockData ? inject(AdminProductsLocalGateway) : inject(AdminProductsApiGateway)),
}); });

View File

@@ -3,7 +3,8 @@ import { signal } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { AdminOrderWatcherService } from './admin-order-watcher.service'; import { AdminOrderWatcherService } from './admin-order-watcher.service';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; import { AdminOrdersGateway } from '../../orders/services/admin-orders-gateway.interface';
import { ADMIN_ORDERS_GATEWAY } from '../../orders/services/admin-orders-gateway.token';
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model'; import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
import { AdminAuthService } from '@marketplaces/auth'; import { AdminAuthService } from '@marketplaces/auth';
@@ -65,7 +66,7 @@ describe('AdminOrderWatcherService', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway }, { provide: ADMIN_ORDERS_GATEWAY, useValue: fakeGateway() as unknown as AdminOrdersGateway },
{ provide: AdminAuthService, useValue: fakeAdminAuth as unknown as AdminAuthService }, { provide: AdminAuthService, useValue: fakeAdminAuth as unknown as AdminAuthService },
], ],
}); });
@@ -192,7 +193,7 @@ describe('AdminOrderWatcherService', () => {
})); }));
it('stop() clears the interval so no further polls occur', fakeAsync(() => { it('stop() clears the interval so no further polls occur', fakeAsync(() => {
const gateway = TestBed.inject(AdminOrdersLocalGateway); const gateway = TestBed.inject(ADMIN_ORDERS_GATEWAY);
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough(); const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();
service.start(); service.start();
@@ -207,7 +208,7 @@ describe('AdminOrderWatcherService', () => {
})); }));
it('reacts to AdminAuthService.isAuthenticated: starts, stops on logout, and resumes on re-login', fakeAsync(() => { it('reacts to AdminAuthService.isAuthenticated: starts, stops on logout, and resumes on re-login', fakeAsync(() => {
const gateway = TestBed.inject(AdminOrdersLocalGateway); const gateway = TestBed.inject(ADMIN_ORDERS_GATEWAY);
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough(); const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();
// Starts authenticated -> the effect should start polling on its own, // Starts authenticated -> the effect should start polling on its own,

View File

@@ -0,0 +1,34 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
/**
* No dedicated transactions contract doc exists yet - this reads as the
* admin-facing view of Payment/PaymentEvent from
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6, at the
* conventional /api/admin/v2/{resource} path used throughout that contract set.
*/
@Injectable({ providedIn: 'root' })
export class AdminTransactionsApiGateway implements AdminTransactionsGateway {
private readonly http = inject(HttpClient);
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<AdminTransactionsListResult>('/api/admin/v2/transactions', { params });
}
retryFailed(id: string): Observable<AdminTransaction | null> {
return this.http.post<AdminTransaction | null>(`/api/admin/v2/transactions/${encodeURIComponent(id)}/retry`, {});
}
setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null> {
return this.http.patch<AdminTransaction | null>(`/api/admin/v2/transactions/${encodeURIComponent(id)}`, { flagged });
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface'; import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
import { AdminTransactionsLocalGateway } from './admin-transactions-local.gateway'; import { AdminTransactionsLocalGateway } from './admin-transactions-local.gateway';
import { AdminTransactionsApiGateway } from './admin-transactions-api.gateway';
/** Swap point for a real Transactions backend - see BACKEND-API-REFERENCE.md §8 (no seam existed before this token). */ /** Swap point for docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6. */
export const ADMIN_TRANSACTIONS_GATEWAY = new InjectionToken<AdminTransactionsGateway>('ADMIN_TRANSACTIONS_GATEWAY', { export const ADMIN_TRANSACTIONS_GATEWAY = new InjectionToken<AdminTransactionsGateway>('ADMIN_TRANSACTIONS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminTransactionsLocalGateway), factory: () => (environment.useMockData ? inject(AdminTransactionsLocalGateway) : inject(AdminTransactionsApiGateway)),
}); });

View File

@@ -0,0 +1,64 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
import { AdminUsersGateway } from './admin-users-gateway.interface';
/**
* Contract: docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §8 (team
* invite/list/role/revoke on /api/admin/v2/team). Sessions/audit/status
* endpoints aren't individually specified - inferred as sub-resources of the
* same collection, consistent with the contract's team/{userId} pattern.
*/
@Injectable({ providedIn: 'root' })
export class AdminUsersApiGateway implements AdminUsersGateway {
private readonly http = inject(HttpClient);
loadUsers(): Observable<AdminUser[]> {
return this.http.get<AdminUser[]>('/api/admin/v2/team');
}
loadRoles(): Observable<AdminUserRoleRecord[]> {
return this.http.get<AdminUserRoleRecord[]>('/api/admin/v2/team/roles');
}
loadInvitations(): Observable<AdminInvitation[]> {
return this.http.get<AdminInvitation[]>('/api/admin/v2/team/invitations');
}
loadSessions(userId: string): Observable<AdminSession[]> {
return this.http.get<AdminSession[]>(`/api/admin/v2/team/${encodeURIComponent(userId)}/sessions`);
}
loadAudit(userId: string): Observable<AdminUserAuditEntry[]> {
return this.http.get<AdminUserAuditEntry[]>(`/api/admin/v2/audit`, { params: { actor: userId } });
}
setUserRole(userId: string, roleId: string): Observable<AdminUser | null> {
return this.http.patch<AdminUser | null>(`/api/admin/v2/team/${encodeURIComponent(userId)}`, { role: roleId });
}
setUserStatus(userId: string, status: AdminUserStatus): Observable<AdminUser | null> {
return this.http.patch<AdminUser | null>(`/api/admin/v2/team/${encodeURIComponent(userId)}/status`, { status });
}
inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable<AdminInvitation> {
// §8's example body is { email, role, marketplaceId } - `scope` here is
// 'marketplace' | 'office', not an id, so it is sent as its own field
// rather than forced into marketplaceId.
return this.http.post<AdminInvitation>('/api/admin/v2/team/invite', { email, role: roleId, scope });
}
revokeInvitation(id: string): Observable<void> {
return this.http
.delete(`/api/admin/v2/team/invitations/${encodeURIComponent(id)}`)
.pipe(map(() => undefined));
}
revokeSession(sessionId: string): Observable<void> {
return this.http
.delete(`/api/admin/v2/team/sessions/${encodeURIComponent(sessionId)}`)
.pipe(map(() => undefined));
}
}

View File

@@ -1,9 +1,11 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { AdminUsersGateway } from './admin-users-gateway.interface'; import { AdminUsersGateway } from './admin-users-gateway.interface';
import { AdminUsersLocalGateway } from './admin-users-local.gateway'; import { AdminUsersLocalGateway } from './admin-users-local.gateway';
import { AdminUsersApiGateway } from './admin-users-api.gateway';
/** Swap point for a real Users backend - see BACKEND-API-REFERENCE.md §8 (no seam existed before this token). */ /** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §8. */
export const ADMIN_USERS_GATEWAY = new InjectionToken<AdminUsersGateway>('ADMIN_USERS_GATEWAY', { export const ADMIN_USERS_GATEWAY = new InjectionToken<AdminUsersGateway>('ADMIN_USERS_GATEWAY', {
providedIn: 'root', providedIn: 'root',
factory: () => inject(AdminUsersLocalGateway), factory: () => (environment.useMockData ? inject(AdminUsersLocalGateway) : inject(AdminUsersApiGateway)),
}); });