Architectural overview

Shape

Internal Admin is a single-page Angular application — one eagerly-loaded bundle, no lazy route loading in the current route tables, hash-based URLs, deployed as static assets to S3 and served behind CloudFront/nginx. It holds a modest amount of shared state in an NgRx store and delegates all business rules that move money to backend services.

┌──────────────────────────────────────────────────────────────────────┐
│ Presentation                                                         │
│  MainLayoutComponent shell: sidebar, CRM panel, supplier header,     │
│  router outlet, toasts, spinners, dialogs                            │
│  ~420 components, 18 directives, 12 pipes                            │
└───────────┬──────────────────────────────────────────┬───────────────┘
            │ dispatch(action) / select(selector)      │ direct service call
            ▼                                          ▼
┌──────────────────────────────────┐   ┌──────────────────────────────────┐
│ State (NgRx 4)                   │   │ Application services             │
│  12 root slices                  │   │  ~140 @Injectable services       │
│  6 effect classes                │◄──┤  thin HttpClient wrappers        │
│  router-store integration        │   │  plus orchestration services     │
└───────────┬──────────────────────┘   └───────────┬──────────────────────┘
            │                                      │
            └──────────────┬───────────────────────┘
                           ▼
┌──────────────────────────────────────────────────────────────────────┐
│ HTTP boundary                                                        │
│  AuthInterceptor          → attaches X-Session-Token, traps 401       │
│  FetchedRequestsInterceptor → records last 50 requests for debugging  │
│  PendingInterceptorService → drives the global loading spinner        │
│  HttpCacheAbstract        → optional localStorage response cache      │
└───────────┬──────────────────────────────────────────────────────────┘
            ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Backends (≈15 distinct base URLs — see integration-landscape)         │
└──────────────────────────────────────────────────────────────────────┘

Orthogonal to all of the above:
  Guards & resolvers        route admission, supplier key resolution
  Permissions (ngx-permissions) template and route gating
  Feature flags             build + global-settings + session layered toggles
  Iframe bridge             typed postMessage to/from EVA
  Error pipeline            ErrorHandler → parser → toast

How a screen loads

The supplier workspace is the canonical flow and exercises nearly every layer.

1. User navigates to /#/suppliers/<key>/general

2. Router admission, in the order declared on the route:
     RouteAccessResolver      → is there a user with a loginId and an unexpired JWT?
                                if not: remember the URL in sessionStorage, dispatch
                                Logout, redirect to /login
     FeatureFlagsGeneralGuard → wait for auth state, then initialise feature flags
                                (build flags + global settings + session overrides)
     NgxPermissionsGuard      → evaluate the route's permission clause
     SupplierKeyResolver      → read :supplierKey from the parent route and dispatch
                                SetSupplierKey

3. @ngrx/router-store emits ROUTER_NAVIGATION.
   SupplierEffects.navigateToSupplier walks the activated route tree for a
   supplierKey param and fans out three actions:
     SET_SUPPLIER_KEY         → point the supplier slice at this supplier
     SET_TARGET_UUID          → point the CRM slice at the same key
     FETCH_SUPPLIER_DETAILS   → load the aggregate

4. SupplierEffects.getSupplierDetails checks the cache in state
   (supplier.suppliers[key]); on a miss or an explicit refresh it forkJoins:
     SupplierService.fetchSupplierDetails         (internal API)
     FundingParticularsService.fetchFundingParticulars
     SupplierService.getSupplierBalance
     LongTermContractsService.getContractsForSupplier + historical
   For an ADVANCE_AUDITORS user the last three are replaced with of(null).
   Result → UPDATE_SUPPLIER_MODEL.

5. AuthInterceptor stamps X-Session-Token on each request; a 401 dispatches
   Logout with a user-visible message.

6. Components select from the store, render, and edit through
   UPDATE_MODEL_PART actions keyed by formGroupKey.

7. In parallel, CRM effects load the timeline for the same target UUID, and the
   layout reducer matches the URL against its route regexes to decide whether the
   CRM panel and supplier info panel are open, fixed or hidden.

The same skeleton — guards, router-store, cache-checking effect, part-wise edit — recurs across the app. Read state management for the idioms and the suppliers domain for the business meaning.

Layer responsibilities

Layer Owns Must not

Components

Rendering, local form state, dispatching actions, calling services for non-shared data.

Contain business arithmetic that belongs in a service; construct URLs; read environment directly.

Store (actions/reducers/effects)

Shared cross-screen state, orchestration of multi-call loads, caching of already-fetched aggregates.

Hold state that only one component reads; perform HTTP directly in a reducer.

Services

One backend concern each: build the request, map the response into a model, translate errors.

Know about components; keep UI state; hardcode a base URL.

Interceptors

Session token, request recording, spinner, cache.

Contain feature logic.

Guards / resolvers

Route admission and route-parameter plumbing.

Fetch domain aggregates (that is an effect’s job — SupplierKeyResolver dispatches rather than fetches).

Models

Shape and serialisation of API payloads, plus derived getters (isAmazonSeller, isTarget).

Perform I/O.

Dependency rules that actually hold

  • Feature modules depend on shared/, never the reverse. The one systematic exception is shared/ code that reaches into suppliers/model for the supplier types (for example EvaCommunicationService and LookupRepository) — a known wart, not a licence to add more.

  • Every module may depend on store/app.reducers.ts for the AppState type. This makes AppState a de-facto shared contract; changing a slice’s shape is a cross-module change.

  • pyb-material is a leaf: it only re-exports Angular Material.

  • mocks/ and *-mock.service.ts files are test-only seams and must not be referenced from production paths.

  • Configuration flows one way: environment.ts → AppConfigService → services. A handful of services import environment directly (EventsService, EvaService, LookupRepository, PendingReviewService, FeatureFlagsService); new code should take AppConfigService.

Where the app is not layered

Three areas cut across the layering and are worth knowing before you change them:

Supplier "model parts"

Editing a supplier is not a single form submit. Each card owns a formGroupKey and the store keeps three parallel maps (in-edit, saved, errors) keyed by it. The reducer merges saved values into both SupplierDetailsModel and FundingParticularsModel. See partial editing.

CRM timeline

The CRM slice is a normalised store built by hand: crmItems is a uuid→item map and each stream (notes, tasks, …) is an ordered uuid list, with a per-target cache in crmStatesList. It also keeps request headers in sync by subscribing to the store inside CrmService. See CRM.

EVA iframes

Whole regions of the supplier workspace are rendered by another application. The seam is a postMessage protocol, not a component boundary, and state is synchronised explicitly in both directions. See iframe messaging and EVA.

Quality gates

Gate Enforcement

Lint

ESLint with @angular-eslint, @typescript-eslint, prettier, simple-import-sort, unused-imports. npm run lint; Husky pre-commit runs lint-staged.

Format

Prettier — single quotes, semicolons, 2-space indent, printWidth 140, trailing commas. npm run format.

Unit tests

Karma + Jasmine, ChromeHeadlessNoSandbox via Puppeteer, ~400 spec files. npm run test-single; Husky pre-push runs tests.

CI

buildspec-ci.yml runs lint + test:ci with JUnit and Cobertura reports. buildspec.yml runs lint then the environment build. Note that test:ci is currently commented out of the deploy buildspec.

E2E

Cypress 7.x under cypress/. Marked not maintained in the repository README.

See testing for details.