From ffd57f2d18122a09ab842d3f60edb542a4ced50a Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 14:29:50 +0400 Subject: [PATCH] feat: real API gateways for all remaining local-only domains (F17-F39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/analytics-api.gateway.ts | 18 ++++++ .../services/analytics-gateway.token.ts | 4 +- .../cart/services/server-cart-api.gateway.ts | 36 +++++++++++ .../services/server-cart-gateway.token.ts | 4 +- .../services/mall-content-api.gateway.ts | 46 +++++++++++++ .../services/mall-content-gateway.token.ts | 4 +- .../finance/services/finance-api.gateway.ts | 40 ++++++++++++ .../finance/services/finance-gateway.token.ts | 4 +- .../identity/services/vk-id-api.gateway.ts | 27 ++++++++ .../identity/services/vk-id-gateway.token.ts | 4 +- .../services/connector-api.gateway.ts | 43 +++++++++++++ .../services/connector-gateway.token.ts | 4 +- .../services/marketplace-api.gateway.ts | 30 +++++++++ .../services/marketplace-gateway.token.ts | 4 +- .../core/offers/services/offer-api.gateway.ts | 56 ++++++++++++++++ .../offers/services/offer-gateway.token.ts | 4 +- .../services/permission-api.gateway.ts | 19 ++++++ .../services/permission-gateway.token.ts | 6 +- .../sellers/services/seller-api.gateway.ts | 27 ++++++++ .../sellers/services/seller-gateway.token.ts | 4 +- .../facade/admin-analytics.facade.spec.ts | 27 ++++---- .../admin-dashboard-metrics-api.gateway.ts | 19 ++++++ .../admin-dashboard-metrics-gateway.token.ts | 6 +- .../services/admin-moderation-api.gateway.ts | 64 +++++++++++++++++++ .../admin-moderation-gateway.token.ts | 6 +- .../services/admin-monitoring-api.gateway.ts | 33 ++++++++++ .../admin-monitoring-gateway.token.ts | 6 +- .../admin-notifications-api.gateway.ts | 37 +++++++++++ .../admin-notifications-gateway.token.ts | 6 +- .../services/admin-orders-api.gateway.ts | 50 +++++++++++++++ .../services/admin-orders-gateway.token.ts | 6 +- .../services/admin-products-api.gateway.ts | 61 ++++++++++++++++++ .../services/admin-products-gateway.token.ts | 6 +- .../admin-order-watcher.service.spec.ts | 9 +-- .../admin-transactions-api.gateway.ts | 34 ++++++++++ .../admin-transactions-gateway.token.ts | 6 +- .../users/services/admin-users-api.gateway.ts | 64 +++++++++++++++++++ .../services/admin-users-gateway.token.ts | 6 +- 38 files changed, 787 insertions(+), 43 deletions(-) create mode 100644 src/app/core/analytics/services/analytics-api.gateway.ts create mode 100644 src/app/core/cart/services/server-cart-api.gateway.ts create mode 100644 src/app/core/content-modules/services/mall-content-api.gateway.ts create mode 100644 src/app/core/finance/services/finance-api.gateway.ts create mode 100644 src/app/core/identity/services/vk-id-api.gateway.ts create mode 100644 src/app/core/integrations/services/connector-api.gateway.ts create mode 100644 src/app/core/marketplace-registry/services/marketplace-api.gateway.ts create mode 100644 src/app/core/offers/services/offer-api.gateway.ts create mode 100644 src/app/core/permissions/services/permission-api.gateway.ts create mode 100644 src/app/core/sellers/services/seller-api.gateway.ts create mode 100644 src/app/features/admin/dashboard/services/admin-dashboard-metrics-api.gateway.ts create mode 100644 src/app/features/admin/moderation/services/admin-moderation-api.gateway.ts create mode 100644 src/app/features/admin/monitoring/services/admin-monitoring-api.gateway.ts create mode 100644 src/app/features/admin/notifications/services/admin-notifications-api.gateway.ts create mode 100644 src/app/features/admin/orders/services/admin-orders-api.gateway.ts create mode 100644 src/app/features/admin/products/services/admin-products-api.gateway.ts create mode 100644 src/app/features/admin/transactions/services/admin-transactions-api.gateway.ts create mode 100644 src/app/features/admin/users/services/admin-users-api.gateway.ts diff --git a/src/app/core/analytics/services/analytics-api.gateway.ts b/src/app/core/analytics/services/analytics-api.gateway.ts new file mode 100644 index 0000000..4afc113 --- /dev/null +++ b/src/app/core/analytics/services/analytics-api.gateway.ts @@ -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 { + return this.http + .post('/api/v2/storefront/analytics/events', event) + .pipe(map(() => undefined)); + } +} diff --git a/src/app/core/analytics/services/analytics-gateway.token.ts b/src/app/core/analytics/services/analytics-gateway.token.ts index 11d1df2..af1b2d2 100644 --- a/src/app/core/analytics/services/analytics-gateway.token.ts +++ b/src/app/core/analytics/services/analytics-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { AnalyticsGateway } from './analytics-gateway.interface'; import { AnalyticsLocalGateway } from './analytics-local.gateway'; +import { AnalyticsApiGateway } from './analytics-api.gateway'; /** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */ export const ANALYTICS_GATEWAY = new InjectionToken('ANALYTICS_GATEWAY', { providedIn: 'root', - factory: () => inject(AnalyticsLocalGateway), + factory: () => (environment.useMockData ? inject(AnalyticsLocalGateway) : inject(AnalyticsApiGateway)), }); diff --git a/src/app/core/cart/services/server-cart-api.gateway.ts b/src/app/core/cart/services/server-cart-api.gateway.ts new file mode 100644 index 0000000..8558bf7 --- /dev/null +++ b/src/app/core/cart/services/server-cart-api.gateway.ts @@ -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 { + return this.http.post('/api/v2/storefront/cart/lines', { offerId, qty }); + } + + updateLine(lineId: string, qty: number): Observable { + return this.http.patch(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`, { qty }); + } + + removeLine(lineId: string): Observable { + return this.http + .delete(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`) + .pipe(map(() => undefined)); + } + + startCheckout(deliveryOptionId: string, currency: string): Observable { + // §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('/api/v2/storefront/checkout', { currency, deliveryOptionId }); + } +} diff --git a/src/app/core/cart/services/server-cart-gateway.token.ts b/src/app/core/cart/services/server-cart-gateway.token.ts index 39b858c..b622b45 100644 --- a/src/app/core/cart/services/server-cart-gateway.token.ts +++ b/src/app/core/cart/services/server-cart-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { ServerCartGateway } from './server-cart-gateway.interface'; 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. */ export const SERVER_CART_GATEWAY = new InjectionToken('SERVER_CART_GATEWAY', { providedIn: 'root', - factory: () => inject(ServerCartLocalGateway), + factory: () => (environment.useMockData ? inject(ServerCartLocalGateway) : inject(ServerCartApiGateway)), }); diff --git a/src/app/core/content-modules/services/mall-content-api.gateway.ts b/src/app/core/content-modules/services/mall-content-api.gateway.ts new file mode 100644 index 0000000..130b8c8 --- /dev/null +++ b/src/app/core/content-modules/services/mall-content-api.gateway.ts @@ -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 { + return this.http.get('/api/admin/v2/content/shops'); + } + + loadShopCategories(): Observable { + return this.http.get('/api/admin/v2/content/shop-categories'); + } + + loadFloors(): Observable { + return this.http.get('/api/admin/v2/content/floors'); + } + + loadSchemePins(floorId: string): Observable { + return this.http.get( + `/api/admin/v2/content/floors/${encodeURIComponent(floorId)}/pins`, + ); + } + + loadRentListings(): Observable { + return this.http.get('/api/admin/v2/content/rent-listings'); + } + + submitLead(lead: Omit): Observable { + const listingId = lead.rentListingId ?? ''; + return this.http.post( + `/api/admin/v2/content/rent-listings/${encodeURIComponent(listingId)}/leads`, + lead, + ); + } +} diff --git a/src/app/core/content-modules/services/mall-content-gateway.token.ts b/src/app/core/content-modules/services/mall-content-gateway.token.ts index b618b2d..18ca97e 100644 --- a/src/app/core/content-modules/services/mall-content-gateway.token.ts +++ b/src/app/core/content-modules/services/mall-content-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { MallContentGateway } from './mall-content-gateway.interface'; 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. */ export const MALL_CONTENT_GATEWAY = new InjectionToken('MALL_CONTENT_GATEWAY', { providedIn: 'root', - factory: () => inject(MallContentLocalGateway), + factory: () => (environment.useMockData ? inject(MallContentLocalGateway) : inject(MallContentApiGateway)), }); diff --git a/src/app/core/finance/services/finance-api.gateway.ts b/src/app/core/finance/services/finance-api.gateway.ts new file mode 100644 index 0000000..8ad09ee --- /dev/null +++ b/src/app/core/finance/services/finance-api.gateway.ts @@ -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 { + // §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(subscriber => { + subscriber.next([]); + subscriber.complete(); + }); + } + return this.http.get(`/api/admin/v2/orders/${encodeURIComponent(orderId)}/refunds`); + } + + loadReconciliationQueue(): Observable { + return this.http.get('/api/admin/v2/reconciliation/queue'); + } + + resolveReconciliation(id: string, note: string): Observable { + return this.http + .post(`/api/admin/v2/reconciliation/${encodeURIComponent(id)}/resolve`, { note }) + .pipe(map(() => undefined)); + } + + loadSettlements(sellerId?: string): Observable { + const params = sellerId ? new HttpParams().set('sellerId', sellerId) : undefined; + return this.http.get('/api/admin/v2/finance/settlements', { params }); + } +} diff --git a/src/app/core/finance/services/finance-gateway.token.ts b/src/app/core/finance/services/finance-gateway.token.ts index 33c32d8..a51d8ce 100644 --- a/src/app/core/finance/services/finance-gateway.token.ts +++ b/src/app/core/finance/services/finance-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { FinanceGateway } from './finance-gateway.interface'; import { FinanceLocalGateway } from './finance-local.gateway'; +import { FinanceApiGateway } from './finance-api.gateway'; /** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */ export const FINANCE_GATEWAY = new InjectionToken('FINANCE_GATEWAY', { providedIn: 'root', - factory: () => inject(FinanceLocalGateway), + factory: () => (environment.useMockData ? inject(FinanceLocalGateway) : inject(FinanceApiGateway)), }); diff --git a/src/app/core/identity/services/vk-id-api.gateway.ts b/src/app/core/identity/services/vk-id-api.gateway.ts new file mode 100644 index 0000000..f41c82c --- /dev/null +++ b/src/app/core/identity/services/vk-id-api.gateway.ts @@ -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 { + return this.http + .get<{ url: string }>('/api/identity/v1/vk/authorize') + .pipe(map(response => response.url)); + } + + completeCallback(code: string, codeVerifier: string): Observable { + return this.http.post('/api/identity/v1/vk/callback', { code, codeVerifier }); + } +} diff --git a/src/app/core/identity/services/vk-id-gateway.token.ts b/src/app/core/identity/services/vk-id-gateway.token.ts index d33579b..81fa7ab 100644 --- a/src/app/core/identity/services/vk-id-gateway.token.ts +++ b/src/app/core/identity/services/vk-id-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { VkIdGateway } from './vk-id-gateway.interface'; 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. */ export const VK_ID_GATEWAY = new InjectionToken('VK_ID_GATEWAY', { providedIn: 'root', - factory: () => inject(VkIdLocalGateway), + factory: () => (environment.useMockData ? inject(VkIdLocalGateway) : inject(VkIdApiGateway)), }); diff --git a/src/app/core/integrations/services/connector-api.gateway.ts b/src/app/core/integrations/services/connector-api.gateway.ts new file mode 100644 index 0000000..988c239 --- /dev/null +++ b/src/app/core/integrations/services/connector-api.gateway.ts @@ -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 { + return this.http.get('/api/admin/v2/integrations'); + } + + loadDeadLetter(connectorId: string): Observable { + // §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( + `/api/admin/v2/integrations/${encodeURIComponent(connectorId)}/dead-letter`, + ); + } + + replay(deadLetterId: string): Observable { + return this.http + .post(`/api/admin/v2/integrations/dead-letter/${encodeURIComponent(deadLetterId)}/replay`, {}) + .pipe(map(() => undefined)); + } + + pause(connectorId: string): Observable { + return this.http + .patch(`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}`, { status: 'paused' }) + .pipe(map(() => undefined)); + } + + resume(connectorId: string): Observable { + return this.http + .patch(`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}`, { status: 'active' }) + .pipe(map(() => undefined)); + } +} diff --git a/src/app/core/integrations/services/connector-gateway.token.ts b/src/app/core/integrations/services/connector-gateway.token.ts index 8b97d29..f416cdf 100644 --- a/src/app/core/integrations/services/connector-gateway.token.ts +++ b/src/app/core/integrations/services/connector-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { ConnectorGateway } from './connector-gateway.interface'; import { ConnectorLocalGateway } from './connector-local.gateway'; +import { ConnectorApiGateway } from './connector-api.gateway'; /** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */ export const CONNECTOR_GATEWAY = new InjectionToken('CONNECTOR_GATEWAY', { providedIn: 'root', - factory: () => inject(ConnectorLocalGateway), + factory: () => (environment.useMockData ? inject(ConnectorLocalGateway) : inject(ConnectorApiGateway)), }); diff --git a/src/app/core/marketplace-registry/services/marketplace-api.gateway.ts b/src/app/core/marketplace-registry/services/marketplace-api.gateway.ts new file mode 100644 index 0000000..eff876f --- /dev/null +++ b/src/app/core/marketplace-registry/services/marketplace-api.gateway.ts @@ -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 { + // 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('/api/admin/v2/marketplaces'); + } + + loadDomains(marketplaceId: string): Observable { + return this.http.get( + `/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/domains`, + ); + } + + loadLifecycle(marketplaceId: string): Observable { + return this.http.get( + `/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/lifecycle`, + ); + } +} diff --git a/src/app/core/marketplace-registry/services/marketplace-gateway.token.ts b/src/app/core/marketplace-registry/services/marketplace-gateway.token.ts index 216c999..f1c492b 100644 --- a/src/app/core/marketplace-registry/services/marketplace-gateway.token.ts +++ b/src/app/core/marketplace-registry/services/marketplace-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { MarketplaceGateway } from './marketplace-gateway.interface'; import { MarketplaceLocalGateway } from './marketplace-local.gateway'; +import { MarketplaceApiGateway } from './marketplace-api.gateway'; /** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */ export const MARKETPLACE_GATEWAY = new InjectionToken('MARKETPLACE_GATEWAY', { providedIn: 'root', - factory: () => inject(MarketplaceLocalGateway), + factory: () => (environment.useMockData ? inject(MarketplaceLocalGateway) : inject(MarketplaceApiGateway)), }); diff --git a/src/app/core/offers/services/offer-api.gateway.ts b/src/app/core/offers/services/offer-api.gateway.ts new file mode 100644 index 0000000..bd076a6 --- /dev/null +++ b/src/app/core/offers/services/offer-api.gateway.ts @@ -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 { + const params = sellerId ? new HttpParams().set('sellerId', sellerId) : undefined; + return this.http.get('/api/admin/v2/offers', { params }); + } + + lookup(query: OfferLookupQuery): Observable { + let params = new HttpParams(); + for (const [key, value] of Object.entries(query)) { + if (value) { + params = params.set(key, value); + } + } + return this.http.get('/api/admin/v2/offers/lookup', { params }); + } + + loadInventory(offerId: string): Observable { + // §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( + `/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; + }), + ); + } +} diff --git a/src/app/core/offers/services/offer-gateway.token.ts b/src/app/core/offers/services/offer-gateway.token.ts index 8cb5ae5..a237433 100644 --- a/src/app/core/offers/services/offer-gateway.token.ts +++ b/src/app/core/offers/services/offer-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { OfferGateway } from './offer-gateway.interface'; 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. */ export const OFFER_GATEWAY = new InjectionToken('OFFER_GATEWAY', { providedIn: 'root', - factory: () => inject(OfferLocalGateway), + factory: () => (environment.useMockData ? inject(OfferLocalGateway) : inject(OfferApiGateway)), }); diff --git a/src/app/core/permissions/services/permission-api.gateway.ts b/src/app/core/permissions/services/permission-api.gateway.ts new file mode 100644 index 0000000..03b5750 --- /dev/null +++ b/src/app/core/permissions/services/permission-api.gateway.ts @@ -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 { + return this.http.get('/api/identity/v1/session/permissions'); + } + + loadAuditLog(): Observable { + return this.http.get('/api/admin/v2/audit'); + } +} diff --git a/src/app/core/permissions/services/permission-gateway.token.ts b/src/app/core/permissions/services/permission-gateway.token.ts index c650807..c11bdb8 100644 --- a/src/app/core/permissions/services/permission-gateway.token.ts +++ b/src/app/core/permissions/services/permission-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { PermissionGateway } from './permission-gateway.interface'; 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('PERMISSION_GATEWAY', { providedIn: 'root', - factory: () => inject(PermissionLocalGateway), + factory: () => (environment.useMockData ? inject(PermissionLocalGateway) : inject(PermissionApiGateway)), }); diff --git a/src/app/core/sellers/services/seller-api.gateway.ts b/src/app/core/sellers/services/seller-api.gateway.ts new file mode 100644 index 0000000..1d03bda --- /dev/null +++ b/src/app/core/sellers/services/seller-api.gateway.ts @@ -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 { + // §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('/api/admin/v2/sellers'); + } + + loadTeam(sellerId: string): Observable { + // §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(`/api/admin/v2/sellers/${encodeURIComponent(sellerId)}/team`); + } +} diff --git a/src/app/core/sellers/services/seller-gateway.token.ts b/src/app/core/sellers/services/seller-gateway.token.ts index 731b28f..c1972e7 100644 --- a/src/app/core/sellers/services/seller-gateway.token.ts +++ b/src/app/core/sellers/services/seller-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; import { SellerGateway } from './seller-gateway.interface'; import { SellerLocalGateway } from './seller-local.gateway'; +import { SellerApiGateway } from './seller-api.gateway'; /** Swap point for docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. */ export const SELLER_GATEWAY = new InjectionToken('SELLER_GATEWAY', { providedIn: 'root', - factory: () => inject(SellerLocalGateway), + factory: () => (environment.useMockData ? inject(SellerLocalGateway) : inject(SellerApiGateway)), }); diff --git a/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts b/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts index 275eea5..4aed747 100644 --- a/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts +++ b/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts @@ -1,10 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { AdminAnalyticsFacade } from './admin-analytics.facade'; -import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; -import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway'; +import { AdminOrdersGateway } from '../../orders/services/admin-orders-gateway.interface'; +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 { 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 { AdminOrder } from '../../orders/models/admin-order.model'; @@ -23,17 +26,17 @@ function makeOrder(overrides: Partial = {}): AdminOrder { describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => { let facade: AdminAnalyticsFacade; - let ordersGateway: jasmine.SpyObj; - let productsGateway: jasmine.SpyObj; + let ordersGateway: jasmine.SpyObj; + let productsGateway: jasmine.SpyObj; let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>; - let moderationGateway: jasmine.SpyObj; + let moderationGateway: jasmine.SpyObj; let dashboardFacade: jasmine.SpyObj; function configure(bootstrapPresent: boolean): void { - ordersGateway = jasmine.createSpyObj('AdminOrdersLocalGateway', ['loadOrders']); - productsGateway = jasmine.createSpyObj('AdminProductsLocalGateway', ['loadProducts']); + ordersGateway = jasmine.createSpyObj('ADMIN_ORDERS_GATEWAY', ['loadOrders']); + productsGateway = jasmine.createSpyObj('ADMIN_PRODUCTS_GATEWAY', ['loadProducts']); categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']); - moderationGateway = jasmine.createSpyObj('AdminModerationLocalGateway', ['loadReviews']); + moderationGateway = jasmine.createSpyObj('ADMIN_MODERATION_GATEWAY', ['loadReviews']); dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [ 'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount', ]); @@ -50,10 +53,10 @@ describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => { TestBed.configureTestingModule({ providers: [ - { provide: AdminOrdersLocalGateway, useValue: ordersGateway }, - { provide: AdminProductsLocalGateway, useValue: productsGateway }, + { provide: ADMIN_ORDERS_GATEWAY, useValue: ordersGateway }, + { provide: ADMIN_PRODUCTS_GATEWAY, useValue: productsGateway }, { provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway }, - { provide: AdminModerationLocalGateway, useValue: moderationGateway }, + { provide: ADMIN_MODERATION_GATEWAY, useValue: moderationGateway }, { provide: AdminDashboardFacade, useValue: dashboardFacade }, ], }); diff --git a/src/app/features/admin/dashboard/services/admin-dashboard-metrics-api.gateway.ts b/src/app/features/admin/dashboard/services/admin-dashboard-metrics-api.gateway.ts new file mode 100644 index 0000000..6daeedf --- /dev/null +++ b/src/app/features/admin/dashboard/services/admin-dashboard-metrics-api.gateway.ts @@ -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 { + return this.http.get('/api/admin/v2/dashboard/metrics'); + } +} diff --git a/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts b/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts index 554acc8..2ab37b2 100644 --- a/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts +++ b/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface'; 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('ADMIN_DASHBOARD_METRICS_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminDashboardMetricsLocalGateway), + factory: () => (environment.useMockData ? inject(AdminDashboardMetricsLocalGateway) : inject(AdminDashboardMetricsApiGateway)), }); diff --git a/src/app/features/admin/moderation/services/admin-moderation-api.gateway.ts b/src/app/features/admin/moderation/services/admin-moderation-api.gateway.ts new file mode 100644 index 0000000..4c3a4be --- /dev/null +++ b/src/app/features/admin/moderation/services/admin-moderation-api.gateway.ts @@ -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 { + 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('/api/admin/v2/moderation/reviews', { params }); + } + + loadReview(id: string): Observable { + return this.http.get(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`); + } + + setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable { + return this.http.patch(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { status, note }); + } + + setReviewVisible(id: string, visible: boolean): Observable { + return this.http.patch(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { visible }); + } + + setReviewPinned(id: string, pinned: boolean): Observable { + return this.http.patch(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { pinned }); + } + + setReviewFeatured(id: string, featured: boolean): Observable { + return this.http.patch(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`, { featured }); + } + + addModeratorNote(id: string, note: string): Observable { + return this.http.post(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}/notes`, { note }); + } + + deleteReview(id: string): Observable { + return this.http + .delete(`/api/admin/v2/moderation/reviews/${encodeURIComponent(id)}`) + .pipe(map(() => undefined)); + } + + loadReports(): Observable { + return this.http.get('/api/admin/v2/moderation/reports'); + } + + setReportStatus(id: string, status: AdminReportStatus): Observable { + return this.http.patch(`/api/admin/v2/moderation/reports/${encodeURIComponent(id)}`, { status }); + } +} diff --git a/src/app/features/admin/moderation/services/admin-moderation-gateway.token.ts b/src/app/features/admin/moderation/services/admin-moderation-gateway.token.ts index bf313ce..b860008 100644 --- a/src/app/features/admin/moderation/services/admin-moderation-gateway.token.ts +++ b/src/app/features/admin/moderation/services/admin-moderation-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminModerationGateway } from './admin-moderation-gateway.interface'; 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('ADMIN_MODERATION_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminModerationLocalGateway), + factory: () => (environment.useMockData ? inject(AdminModerationLocalGateway) : inject(AdminModerationApiGateway)), }); diff --git a/src/app/features/admin/monitoring/services/admin-monitoring-api.gateway.ts b/src/app/features/admin/monitoring/services/admin-monitoring-api.gateway.ts new file mode 100644 index 0000000..423a746 --- /dev/null +++ b/src/app/features/admin/monitoring/services/admin-monitoring-api.gateway.ts @@ -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 { + 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('/api/admin/v2/monitoring/events', { params }); + } + + loadQueues(): Observable { + return this.http.get('/api/admin/v2/monitoring/queues'); + } + + loadWebhooks(): Observable { + return this.http.get('/api/admin/v2/monitoring/webhooks'); + } +} diff --git a/src/app/features/admin/monitoring/services/admin-monitoring-gateway.token.ts b/src/app/features/admin/monitoring/services/admin-monitoring-gateway.token.ts index 6689199..e95daf0 100644 --- a/src/app/features/admin/monitoring/services/admin-monitoring-gateway.token.ts +++ b/src/app/features/admin/monitoring/services/admin-monitoring-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface'; 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('ADMIN_MONITORING_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminMonitoringLocalGateway), + factory: () => (environment.useMockData ? inject(AdminMonitoringLocalGateway) : inject(AdminMonitoringApiGateway)), }); diff --git a/src/app/features/admin/notifications/services/admin-notifications-api.gateway.ts b/src/app/features/admin/notifications/services/admin-notifications-api.gateway.ts new file mode 100644 index 0000000..a485bc7 --- /dev/null +++ b/src/app/features/admin/notifications/services/admin-notifications-api.gateway.ts @@ -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 { + 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('/api/admin/v2/notifications', { params }); + } + + markRead(id: string): Observable { + return this.http + .patch(`/api/admin/v2/notifications/${encodeURIComponent(id)}/read`, {}) + .pipe(map(() => undefined)); + } + + markAllRead(): Observable { + // 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)); + } +} diff --git a/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts b/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts index 5d7ecb9..91e313e 100644 --- a/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts +++ b/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminNotificationsGateway } from './admin-notifications-gateway.interface'; 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('ADMIN_NOTIFICATIONS_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminNotificationsLocalGateway), + factory: () => (environment.useMockData ? inject(AdminNotificationsLocalGateway) : inject(AdminNotificationsApiGateway)), }); diff --git a/src/app/features/admin/orders/services/admin-orders-api.gateway.ts b/src/app/features/admin/orders/services/admin-orders-api.gateway.ts new file mode 100644 index 0000000..c43f71b --- /dev/null +++ b/src/app/features/admin/orders/services/admin-orders-api.gateway.ts @@ -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 { + 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('/api/admin/v2/orders', { params }); + } + + loadOrder(id: string): Observable { + return this.http.get(`/api/admin/v2/orders/${encodeURIComponent(id)}`); + } + + updateStatus(id: string, status: AdminOrderStatus): Observable { + return this.http.patch(`/api/admin/v2/orders/${encodeURIComponent(id)}/status`, { status }); + } + + requestRefund(id: string): Observable { + return this.http.post(`/api/admin/v2/orders/${encodeURIComponent(id)}/refund-request`, {}); + } + + addNote(id: string, note: string, internal: boolean): Observable { + return this.http.post(`/api/admin/v2/orders/${encodeURIComponent(id)}/notes`, { note, internal }); + } + + archiveOrder(id: string): Observable { + return this.http.post(`/api/admin/v2/orders/${encodeURIComponent(id)}/archive`, {}); + } + + restoreOrder(id: string): Observable { + return this.http.post(`/api/admin/v2/orders/${encodeURIComponent(id)}/restore`, {}); + } + + deleteOrder(id: string): Observable { + return this.http.delete(`/api/admin/v2/orders/${encodeURIComponent(id)}`).pipe(map(() => undefined)); + } +} diff --git a/src/app/features/admin/orders/services/admin-orders-gateway.token.ts b/src/app/features/admin/orders/services/admin-orders-gateway.token.ts index f916580..42dd680 100644 --- a/src/app/features/admin/orders/services/admin-orders-gateway.token.ts +++ b/src/app/features/admin/orders/services/admin-orders-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminOrdersGateway } from './admin-orders-gateway.interface'; 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('ADMIN_ORDERS_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminOrdersLocalGateway), + factory: () => (environment.useMockData ? inject(AdminOrdersLocalGateway) : inject(AdminOrdersApiGateway)), }); diff --git a/src/app/features/admin/products/services/admin-products-api.gateway.ts b/src/app/features/admin/products/services/admin-products-api.gateway.ts new file mode 100644 index 0000000..25fae92 --- /dev/null +++ b/src/app/features/admin/products/services/admin-products-api.gateway.ts @@ -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 { + 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('/api/admin/v2/products', { params }); + } + + loadProduct(id: string): Observable { + return this.http.get(`/api/admin/v2/products/${encodeURIComponent(id)}`); + } + + loadCategories(): Observable { + return this.http.get('/api/admin/v2/products/categories'); + } + + createProduct(product: AdminProduct): Observable { + return this.http.post('/api/admin/v2/products', product); + } + + updateProduct(product: AdminProduct): Observable { + return this.http.patch(`/api/admin/v2/products/${encodeURIComponent(product.id)}`, product); + } + + deleteProduct(id: string): Observable { + return this.http.delete(`/api/admin/v2/products/${encodeURIComponent(id)}`).pipe(map(() => undefined)); + } + + duplicateProduct(id: string): Observable { + return this.http.post(`/api/admin/v2/products/${encodeURIComponent(id)}/duplicate`, {}); + } + + archiveProduct(id: string): Observable { + return this.http + .post(`/api/admin/v2/products/${encodeURIComponent(id)}/archive`, {}) + .pipe(map(() => undefined)); + } + + restoreProduct(id: string): Observable { + return this.http.post(`/api/admin/v2/products/${encodeURIComponent(id)}/restore`, {}); + } +} diff --git a/src/app/features/admin/products/services/admin-products-gateway.token.ts b/src/app/features/admin/products/services/admin-products-gateway.token.ts index 604d05c..5e414c2 100644 --- a/src/app/features/admin/products/services/admin-products-gateway.token.ts +++ b/src/app/features/admin/products/services/admin-products-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminProductsGateway } from './admin-products-gateway.interface'; 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('ADMIN_PRODUCTS_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminProductsLocalGateway), + factory: () => (environment.useMockData ? inject(AdminProductsLocalGateway) : inject(AdminProductsApiGateway)), }); diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts index 67e24d7..b24fd2e 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -3,7 +3,8 @@ import { signal } from '@angular/core'; 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 { 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 { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; import { AdminAuthService } from '@marketplaces/auth'; @@ -65,7 +66,7 @@ describe('AdminOrderWatcherService', () => { TestBed.configureTestingModule({ providers: [ 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 }, ], }); @@ -192,7 +193,7 @@ describe('AdminOrderWatcherService', () => { })); 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(); service.start(); @@ -207,7 +208,7 @@ describe('AdminOrderWatcherService', () => { })); 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(); // Starts authenticated -> the effect should start polling on its own, diff --git a/src/app/features/admin/transactions/services/admin-transactions-api.gateway.ts b/src/app/features/admin/transactions/services/admin-transactions-api.gateway.ts new file mode 100644 index 0000000..3f8f810 --- /dev/null +++ b/src/app/features/admin/transactions/services/admin-transactions-api.gateway.ts @@ -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 { + 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('/api/admin/v2/transactions', { params }); + } + + retryFailed(id: string): Observable { + return this.http.post(`/api/admin/v2/transactions/${encodeURIComponent(id)}/retry`, {}); + } + + setFraudFlag(id: string, flagged: boolean): Observable { + return this.http.patch(`/api/admin/v2/transactions/${encodeURIComponent(id)}`, { flagged }); + } +} diff --git a/src/app/features/admin/transactions/services/admin-transactions-gateway.token.ts b/src/app/features/admin/transactions/services/admin-transactions-gateway.token.ts index 314bed1..972a892 100644 --- a/src/app/features/admin/transactions/services/admin-transactions-gateway.token.ts +++ b/src/app/features/admin/transactions/services/admin-transactions-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminTransactionsGateway } from './admin-transactions-gateway.interface'; 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('ADMIN_TRANSACTIONS_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminTransactionsLocalGateway), + factory: () => (environment.useMockData ? inject(AdminTransactionsLocalGateway) : inject(AdminTransactionsApiGateway)), }); diff --git a/src/app/features/admin/users/services/admin-users-api.gateway.ts b/src/app/features/admin/users/services/admin-users-api.gateway.ts new file mode 100644 index 0000000..f807b94 --- /dev/null +++ b/src/app/features/admin/users/services/admin-users-api.gateway.ts @@ -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 { + return this.http.get('/api/admin/v2/team'); + } + + loadRoles(): Observable { + return this.http.get('/api/admin/v2/team/roles'); + } + + loadInvitations(): Observable { + return this.http.get('/api/admin/v2/team/invitations'); + } + + loadSessions(userId: string): Observable { + return this.http.get(`/api/admin/v2/team/${encodeURIComponent(userId)}/sessions`); + } + + loadAudit(userId: string): Observable { + return this.http.get(`/api/admin/v2/audit`, { params: { actor: userId } }); + } + + setUserRole(userId: string, roleId: string): Observable { + return this.http.patch(`/api/admin/v2/team/${encodeURIComponent(userId)}`, { role: roleId }); + } + + setUserStatus(userId: string, status: AdminUserStatus): Observable { + return this.http.patch(`/api/admin/v2/team/${encodeURIComponent(userId)}/status`, { status }); + } + + inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable { + // §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('/api/admin/v2/team/invite', { email, role: roleId, scope }); + } + + revokeInvitation(id: string): Observable { + return this.http + .delete(`/api/admin/v2/team/invitations/${encodeURIComponent(id)}`) + .pipe(map(() => undefined)); + } + + revokeSession(sessionId: string): Observable { + return this.http + .delete(`/api/admin/v2/team/sessions/${encodeURIComponent(sessionId)}`) + .pipe(map(() => undefined)); + } +} diff --git a/src/app/features/admin/users/services/admin-users-gateway.token.ts b/src/app/features/admin/users/services/admin-users-gateway.token.ts index 1ee7ae9..d144e46 100644 --- a/src/app/features/admin/users/services/admin-users-gateway.token.ts +++ b/src/app/features/admin/users/services/admin-users-gateway.token.ts @@ -1,9 +1,11 @@ import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import { AdminUsersGateway } from './admin-users-gateway.interface'; 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('ADMIN_USERS_GATEWAY', { providedIn: 'root', - factory: () => inject(AdminUsersLocalGateway), + factory: () => (environment.useMockData ? inject(AdminUsersLocalGateway) : inject(AdminUsersApiGateway)), });