Files
ISA-Frontend/libs/crm/data-access/README.md
Lorenz Hilpert 743d6c1ee9 docs: comprehensive CLAUDE.md overhaul with library reference system
- Restructure CLAUDE.md with clearer sections and updated metadata
- Add research guidelines emphasizing subagent usage and documentation-first approach
- Create library reference guide covering all 61 libraries across 12 domains
- Add automated library reference generation tool
- Complete test coverage for reward order confirmation feature (6 new spec files)
- Refine product info components and adapters with improved documentation
- Update workflows documentation for checkout service
- Fix ESLint issues: case declarations, unused imports, and unused variables
2025-10-22 11:55:04 +02:00

52 KiB

@isa/crm/data-access

A comprehensive Customer Relationship Management (CRM) data access library for Angular applications providing customer, shipping address, payer, and bonus card management with reactive data loading using Angular resources.

Overview

The CRM Data Access library provides a unified interface for managing customer data, shipping addresses, payers, bonus cards, and country information. It integrates with the generated CRM API client and provides intelligent data loading through Angular resources, tab-based state management, Zod schema validation, and type-safe transformations. The library follows modern Angular patterns with signals, resources, and dependency injection.

Table of Contents

Features

  • Customer management - Fetch customer data with configurable eager loading
  • Shipping address operations - Retrieve customer shipping addresses with pagination
  • Bonus card support - Manage customer bonus cards with primary card selection
  • Payer information - Handle assigned payers and payment settings
  • Country data - Cached country information retrieval
  • Angular resources - Reactive data loading with automatic cancellation
  • Tab-based state - Integration with tab service for context-aware data loading
  • Zod validation - Runtime schema validation for all parameters
  • Request cancellation - AbortSignal support for all operations
  • Type-safe transformations - Full TypeScript integration with Zod schemas
  • Comprehensive logging - Integration with @isa/core/logging for debugging
  • Caching support - Automatic caching for country data

Quick Start

1. Import and Inject Services

import { Component, inject } from '@angular/core';
import { CrmSearchService, ShippingAddressService } from '@isa/crm/data-access';

@Component({
  selector: 'app-customer-detail',
  template: '...'
})
export class CustomerDetailComponent {
  #crmSearchService = inject(CrmSearchService);
  #shippingAddressService = inject(ShippingAddressService);
}

2. Fetch Customer Data

async loadCustomer(customerId: number): Promise<void> {
  const response = await this.#crmSearchService.fetchCustomer({
    customerId: customerId,
    eagerLoading: 3  // Load related data
  });

  const customer = response.result;
  console.log(`Customer: ${customer.firstName} ${customer.lastName}`);
  console.log(`Customer number: ${customer.customerNumber}`);
}

3. Use Angular Resources for Reactive Loading

import { Component } from '@angular/core';
import { CustomerResource } from '@isa/crm/data-access';

@Component({
  selector: 'app-customer-view',
  providers: [CustomerResource],
  template: `
    @if (customerResource.resource.value(); as customer) {
      <div>{{ customer.firstName }} {{ customer.lastName }}</div>
    } @else if (customerResource.resource.isLoading()) {
      <div>Loading customer...</div>
    } @else if (customerResource.resource.error()) {
      <div>Error loading customer</div>
    }
  `
})
export class CustomerViewComponent {
  customerResource = inject(CustomerResource);

  ngOnInit() {
    // Trigger customer load
    this.customerResource.params({ customerId: 12345 });
  }
}

4. Fetch Shipping Addresses with Pagination

async loadShippingAddresses(customerId: number): Promise<void> {
  const response = await this.#shippingAddressService.fetchCustomerShippingAddresses({
    customerId: customerId,
    take: 10,  // Pagination: take 10 records
    skip: 0    // Pagination: skip 0 records
  });

  console.log(`Total addresses: ${response.totalCount}`);
  console.log(`Addresses loaded: ${response.result.length}`);

  response.result.forEach(address => {
    console.log(`${address.firstName} ${address.lastName}`);
    console.log(`${address.address?.street}, ${address.address?.city}`);
  });
}

Core Concepts

Customer Data Model

The library provides comprehensive customer data management with the following structure:

interface Customer {
  id: number;                        // Customer ID
  customerNumber: string;            // Customer number
  customerType: CustomerType;        // Customer type enum
  firstName?: string;                // First name
  lastName?: string;                 // Last name
  dateOfBirth?: string;             // Date of birth (ISO format)
  gender?: Gender;                   // Gender enum
  title?: string;                    // Title (Mr., Mrs., etc.)

  // Address information
  address?: Address;                 // Primary address

  // Communication details
  communicationDetails?: CommunicationDetails;  // Email, phone, etc.

  // Organization (for business customers)
  organisation?: Organisation;       // Organization details

  // Related entities
  bonusCard?: EntityContainer<BonusCard>;  // Primary bonus card
  shippingAddresses?: EntityContainer<ShippingAddress>[];  // Shipping addresses
  payers?: AssignedPayer[];         // Assigned payers

  // Customer status
  customerStatus?: number;           // Status code
  statusComment?: string;            // Status comment
  hasOnlineAccount?: boolean;        // Online account flag
  isGuestAccount?: boolean;          // Guest account flag

  // Metadata
  orderCount?: number;               // Total order count
  createdInBranch?: EntityContainer<Branch>;  // Creation branch
  attributes?: EntityContainer<Attribute>[];  // Custom attributes
  linkedRecords?: LinkedRecord[];   // Linked records

  // Comments
  agentComment?: string;             // Agent comment
  deactivationComment?: string;      // Deactivation comment

  // Settings
  preferredPaymentType?: number;     // Preferred payment type
  notificationChannels?: NotificationChannel;  // Notification preferences
  campaignCode?: string;             // Campaign code
  fetchOnDeliveryNote?: boolean;     // Delivery note flag
}

Customer Types

enum CustomerType {
  NotSet = 0,        // Not specified
  Private = 1,       // Private customer
  Business = 2,      // Business customer
  Employee = 4,      // Employee
}

Shipping Address Model

interface ShippingAddress {
  id: number;                        // Shipping address ID
  firstName?: string;                // First name
  lastName?: string;                 // Last name
  gender?: Gender;                   // Gender enum
  title?: string;                    // Title

  // Address details
  address?: Address;                 // Address information

  // Communication
  communicationDetails?: CommunicationDetails;  // Contact information

  // Organization (for business addresses)
  organisation?: Organisation;       // Organization details

  // Address metadata
  type?: number;                     // Address type
  validated?: string;                // Validation timestamp
  validationResult?: number;         // Validation result code

  // Comments
  agentomment?: string;              // Agent comment (typo in API)
}

Bonus Card Model

interface BonusCardInfo {
  id: number;                        // Bonus card ID
  cardNumber?: string;               // Card number
  cardProvider?: number;             // Card provider ID
  bonusValue?: number;               // Current bonus value

  // Card status
  isLocked?: boolean;                // Locked flag
  isPaymentEnabled?: boolean;        // Payment enabled flag
  markedAsLost?: string;            // Lost timestamp

  // Validity
  validFrom?: string;                // Valid from date (ISO format)
  validThrough?: string;             // Valid through date (ISO format)

  // Suspension
  suspensionComment?: string;        // Suspension comment

  // Extended properties (from BonusCardInfo model)
  firstName: string;                 // Card holder first name
  lastName: string;                  // Card holder last name
  isActive: boolean;                 // Active status
  isPrimary: boolean;                // Primary card flag
  totalPoints: number;               // Total points accumulated
}

Payer Model

interface Payer {
  id: number;                        // Payer ID
  payerNumber?: string;              // Payer number
  payerType?: PayerType;            // Payer type enum
  firstName?: string;                // First name
  lastName?: string;                 // Last name
  gender?: Gender;                   // Gender enum
  title?: string;                    // Title

  // Address and communication
  address?: Address;                 // Primary address
  communicationDetails?: CommunicationDetails;  // Contact details
  organisation?: Organisation;       // Organization (for business payers)

  // Payer status
  payerStatus?: PayerStatus;        // Status information
  payerGroup?: string;               // Payer group

  // Payment settings
  paymentTypes?: PaymentSettings[];  // Allowed payment types
  defaultPaymentPeriod?: number;     // Default payment period (days)
  standardInvoiceText?: string;      // Standard invoice text

  // Flags
  isGuestAccount?: boolean;          // Guest account flag

  // Comments
  agentComment?: string;             // Agent comment
  statusComment?: string;            // Status comment
  statusChangeComment?: string;      // Status change comment
  deactivationComment?: string;      // Deactivation comment

  // Label
  label?: EntityContainer<Label>;   // Label information
}

Angular Resource Pattern

The library uses Angular's resource API for reactive data loading:

// Resource automatically manages loading state, errors, and cancellation
const customerResource = resource({
  params: () => ({ customerId: 12345, eagerLoading: 3 }),
  loader: async ({ params, abortSignal }) => {
    const response = await service.fetchCustomer(params, abortSignal);
    return response.result;
  }
});

// In template:
// customerResource.value() - Current data
// customerResource.isLoading() - Loading state
// customerResource.error() - Error state
// customerResource.reload() - Manual reload

Tab-Based State Management

The library integrates with the tab service for context-aware data loading:

// Metadata keys for tab storage
const SELECTED_CUSTOMER_ID = 'crm-data-access.selectedCustomerId';
const SELECTED_SHIPPING_ADDRESS_ID = 'crm-data-access.selectedShippingAddressId';
const SELECTED_PAYER_ADDRESS_ID = 'crm-data-access.selectedPayerAddressId';

// Store selected customer ID in current tab
crmTabMetadataService.setSelectedCustomerId(tabId, 12345);

// Retrieve selected customer ID from tab
const customerId = crmTabMetadataService.selectedCustomerId(tabId);

Validation with Zod

All input parameters are validated using Zod schemas:

// Example: Customer fetch params validation
const params = {
  customerId: '123',        // String coerced to number
  eagerLoading: 3
};

// Validation happens automatically
const result = await service.fetchCustomer(params);
// Throws ZodError if validation fails

API Reference

CrmSearchService

Main service for fetching customer and bonus card data.

fetchCustomer(params, abortSignal?): Promise<ResponseArgs<Customer>>

Fetches a single customer by ID with optional eager loading.

Parameters:

  • params: FetchCustomerInput - Customer fetch parameters (automatically validated)
    • customerId: number - Customer ID (required)
    • eagerLoading?: number - Eager loading level (optional, controls related data loading)
  • abortSignal?: AbortSignal - Optional abort signal for request cancellation

Returns: Promise resolving to ResponseArgs containing Customer data

Eager Loading Levels:

  • 0 - Minimal data (customer record only)
  • 1 - Basic related data
  • 2 - Extended related data
  • 3 - Full related data (default in resources)

Example:

const response = await service.fetchCustomer({
  customerId: 12345,
  eagerLoading: 3
});

const customer = response.result;
console.log(`${customer.firstName} ${customer.lastName}`);
console.log(`Bonus card: ${customer.bonusCard?.entity?.cardNumber}`);

fetchCustomerCards(params, abortSignal?): Promise<ResponseArgs<BonusCardInfo[]>>

Fetches all bonus cards for a customer.

Parameters:

  • params: FetchCustomerCardsInput - Bonus card fetch parameters
    • customerId: number - Customer ID (required)
  • abortSignal?: AbortSignal - Optional abort signal for request cancellation

Returns: Promise resolving to ResponseArgs containing array of BonusCardInfo

Example:

const response = await service.fetchCustomerCards({
  customerId: 12345
});

const cards = response.result;
cards.forEach(card => {
  console.log(`Card ${card.cardNumber}: ${card.totalPoints} points`);
  if (card.isPrimary) {
    console.log('This is the primary card');
  }
});

ShippingAddressService

Service for managing customer shipping addresses.

fetchCustomerShippingAddresses(params, abortSignal?): Promise<ListResponseArgs<ShippingAddress>>

Fetches shipping addresses for a customer with pagination support.

Parameters:

  • params: FetchCustomerShippingAddressesInput - Shipping address fetch parameters
    • customerId: number - Customer ID (required)
    • take?: number | null - Number of records to retrieve (optional, for pagination)
    • skip?: number | null - Number of records to skip (optional, for pagination)
  • abortSignal?: AbortSignal - Optional abort signal for request cancellation

Returns: Promise resolving to ListResponseArgs with ShippingAddress array and totalCount

Example:

// Fetch first 10 shipping addresses
const response = await service.fetchCustomerShippingAddresses({
  customerId: 12345,
  take: 10,
  skip: 0
});

console.log(`Total addresses: ${response.totalCount}`);
console.log(`Loaded: ${response.result.length}`);

response.result.forEach(address => {
  console.log(`${address.firstName} ${address.lastName}`);
  console.log(`${address.address?.city}, ${address.address?.zipCode}`);
});

// Fetch next page
const nextPage = await service.fetchCustomerShippingAddresses({
  customerId: 12345,
  take: 10,
  skip: 10
});

fetchShippingAddress(params, abortSignal?): Promise<ResponseArgs<ShippingAddress>>

Fetches a single shipping address by ID.

Parameters:

  • params: FetchShippingAddressInput - Shipping address fetch parameters
    • shippingAddressId: number - Shipping address ID (required)
  • abortSignal?: AbortSignal - Optional abort signal for request cancellation

Returns: Promise resolving to ResponseArgs containing ShippingAddress

Example:

const response = await service.fetchShippingAddress({
  shippingAddressId: 67890
});

const address = response.result;
console.log(`Address: ${address.address?.street}`);
console.log(`City: ${address.address?.city}`);
console.log(`Validated: ${address.validated}`);

CountryService

Service for retrieving country information with automatic caching.

getCountries(abortSignal?): Promise<Country[]>

Retrieves all available countries with automatic caching.

Parameters:

  • abortSignal?: AbortSignal - Optional abort signal for request cancellation

Returns: Promise resolving to array of Country objects

Caching: Results are automatically cached using the @Cache() decorator

Example:

const countries = await service.getCountries();

countries.forEach(country => {
  console.log(`${country.name} (${country.code})`);
});

// Second call returns cached result
const cachedCountries = await service.getCountries();

CrmTabMetadataService

Service for managing CRM-related metadata in tabs.

selectedCustomerId(tabId): number | undefined

Retrieves the selected customer ID from tab metadata.

Parameters:

  • tabId: number - Tab ID

Returns: Customer ID or undefined if not set

setSelectedCustomerId(tabId, customerId)

Stores the selected customer ID in tab metadata.

Parameters:

  • tabId: number - Tab ID
  • customerId: number | undefined - Customer ID to store

selectedShippingAddressId(tabId): number | undefined

Retrieves the selected shipping address ID from tab metadata.

Parameters:

  • tabId: number - Tab ID

Returns: Shipping address ID or undefined if not set

setSelectedShippingAddressId(tabId, shippingAddressId)

Stores the selected shipping address ID in tab metadata.

Parameters:

  • tabId: number - Tab ID
  • shippingAddressId: number | undefined - Shipping address ID to store

selectedPayerId(tabId): number | undefined

Retrieves the selected payer ID from tab metadata.

Parameters:

  • tabId: number - Tab ID

Returns: Payer ID or undefined if not set

setSelectedPayerId(tabId, payerAddressId)

Stores the selected payer ID in tab metadata.

Parameters:

  • tabId: number - Tab ID
  • payerAddressId: number | undefined - Payer ID to store

Example:

// Store customer ID in current tab
const tabId = tabService.activatedTabId();
crmTabMetadataService.setSelectedCustomerId(tabId, 12345);

// Later, retrieve customer ID from same tab
const customerId = crmTabMetadataService.selectedCustomerId(tabId);
if (customerId) {
  console.log(`Selected customer: ${customerId}`);
}

CustomerFacade

Pass-through facade for customer operations.

fetchCustomer(params, abortSignal?): Promise<Customer | undefined>

Simplified customer fetch that returns the customer directly (unwraps ResponseArgs).

Parameters:

  • params: FetchCustomerInput - Customer fetch parameters
  • abortSignal?: AbortSignal - Optional abort signal

Returns: Promise resolving to Customer or undefined

Example:

const customer = await facade.fetchCustomer({
  customerId: 12345,
  eagerLoading: 2
});

if (customer) {
  console.log(`Customer: ${customer.firstName} ${customer.lastName}`);
}

CustomerCardsFacade

Pass-through facade for bonus card operations.

get(params, abortSignal?): Promise<ResponseArgs<BonusCardInfo[]>>

Fetches customer bonus cards.

Parameters:

  • params: FetchCustomerCardsInput - Bonus card fetch parameters
  • abortSignal?: AbortSignal - Optional abort signal

Returns: Promise resolving to ResponseArgs with BonusCardInfo array

Resources

Angular resource-based data loading classes for reactive data management.

CustomerResource

Base resource for loading customer data.

Properties:

  • resource: Resource<Customer | undefined> - Angular resource for customer data

Methods:

  • params(params) - Update resource parameters
    • customerId?: number - Customer ID
    • eagerLoading?: number - Eager loading level (default: 3)

Example:

@Component({
  providers: [CustomerResource]
})
export class CustomerComponent {
  customerResource = inject(CustomerResource);

  ngOnInit() {
    this.customerResource.params({ customerId: 12345 });
  }
}

SelectedCustomerResource

Extended resource that automatically loads customer based on tab metadata.

Additional Features:

  • Automatically syncs with CrmTabMetadataService.selectedCustomerId
  • Reactively updates when tab selection changes

Methods:

  • setEagerLoading(eagerLoading) - Update eager loading level

Example:

@Component({
  providers: [SelectedCustomerResource]
})
export class CustomerDetailComponent {
  selectedCustomerResource = inject(SelectedCustomerResource);

  ngOnInit() {
    // Automatically loads customer based on tab metadata
    this.selectedCustomerResource.setEagerLoading(3);
  }
}

CustomerShippingAddressesResource

Base resource for loading customer shipping addresses.

Properties:

  • resource: Resource<ShippingAddress[] | undefined> - Angular resource for shipping addresses

Methods:

  • params(params) - Update resource parameters
    • customerId?: number - Customer ID
    • take?: number | null - Pagination: number of records
    • skip?: number | null - Pagination: offset

Example:

@Component({
  providers: [CustomerShippingAddressesResource]
})
export class ShippingAddressesComponent {
  addressesResource = inject(CustomerShippingAddressesResource);

  loadAddresses(customerId: number, page: number, pageSize: number) {
    this.addressesResource.params({
      customerId,
      take: pageSize,
      skip: page * pageSize
    });
  }
}

SelectedCustomerShippingAddressesResource

Extended resource that automatically loads addresses based on tab metadata.

Additional Features:

  • Automatically syncs with CrmTabMetadataService.selectedCustomerId
  • Reactively updates when tab customer selection changes

CustomerShippingAddressResource

Base resource for loading a single shipping address.

Properties:

  • resource: Resource<ShippingAddress | undefined> - Angular resource for single address

Methods:

  • params(params) - Update resource parameters
    • shippingAddressId?: number - Shipping address ID

SelectedCustomerShippingAddressResource

Extended resource that automatically loads address based on tab metadata.

Additional Features:

  • Automatically syncs with CrmTabMetadataService.selectedShippingAddressId

CustomerBonusCardsResource

Base resource for loading customer bonus cards.

Properties:

  • resource: Resource<BonusCardInfo[] | undefined> - Angular resource for bonus cards

Methods:

  • params(params) - Update resource parameters
    • customerId?: number - Customer ID

SelectedCustomerBonusCardsResource

Extended resource that automatically loads cards based on tab metadata.

Additional Features:

  • Automatically syncs with CrmTabMetadataService.selectedCustomerId

CountryResource

Resource for loading country list with automatic caching.

Properties:

  • resource: Resource<Country[]> - Angular resource for countries

Features:

  • Automatically caches results via CountryService
  • No parameters required (loads all countries)

Example:

@Component({
  providers: [CountryResource]
})
export class CountrySelectComponent {
  countryResource = inject(CountryResource);

  // In template: countryResource.resource.value()
}

Helper Functions

getCustomerName(customer): string

Generates a formatted customer name from first and last name.

Parameters:

  • customer: { firstName?: string; lastName?: string } | undefined - Customer object

Returns: Formatted name string (trimmed, handles missing values)

Example:

import { getCustomerName } from '@isa/crm/data-access';

const name = getCustomerName(customer);
console.log(name);  // "John Doe"

const nameWithMissing = getCustomerName({ firstName: 'John' });
console.log(nameWithMissing);  // "John"

const emptyName = getCustomerName({});
console.log(emptyName);  // ""

getPrimaryBonusCard(bonusCards): BonusCardInfo | undefined

Finds the primary bonus card from an array of bonus cards.

Parameters:

  • bonusCards: BonusCardInfo[] - Array of bonus cards

Returns: Primary bonus card or undefined if none found

Example:

import { getPrimaryBonusCard } from '@isa/crm/data-access';

const primaryCard = getPrimaryBonusCard(bonusCards);
if (primaryCard) {
  console.log(`Primary card: ${primaryCard.cardNumber}`);
  console.log(`Points: ${primaryCard.totalPoints}`);
}

Usage Examples

Loading Customer with Eager Loading

import { Component, inject } from '@angular/core';
import { CrmSearchService } from '@isa/crm/data-access';

@Component({
  selector: 'app-customer-detail',
  template: '...'
})
export class CustomerDetailComponent {
  #crmSearchService = inject(CrmSearchService);

  async loadCustomerWithAllData(customerId: number): Promise<void> {
    const response = await this.#crmSearchService.fetchCustomer({
      customerId: customerId,
      eagerLoading: 3  // Load all related data
    });

    const customer = response.result;

    // Access customer data
    console.log(`Name: ${customer.firstName} ${customer.lastName}`);
    console.log(`Customer #: ${customer.customerNumber}`);
    console.log(`Email: ${customer.communicationDetails?.email}`);

    // Access related data loaded via eager loading
    if (customer.bonusCard?.entity) {
      console.log(`Bonus card: ${customer.bonusCard.entity.cardNumber}`);
    }

    if (customer.payers && customer.payers.length > 0) {
      console.log(`Assigned payers: ${customer.payers.length}`);
      const defaultPayer = customer.payers.find(p => p.isDefault === 'true');
      if (defaultPayer?.payer?.entity) {
        console.log(`Default payer: ${defaultPayer.payer.entity.payerNumber}`);
      }
    }
  }
}

Using Resource for Reactive Customer Loading

import { Component, inject } from '@angular/core';
import { CustomerResource } from '@isa/crm/data-access';

@Component({
  selector: 'app-customer-view',
  standalone: true,
  providers: [CustomerResource],
  template: `
    @if (customerResource.resource.value(); as customer) {
      <div class="customer-details">
        <h2>{{ customer.firstName }} {{ customer.lastName }}</h2>
        <p>Customer Number: {{ customer.customerNumber }}</p>
        <p>Email: {{ customer.communicationDetails?.email }}</p>

        @if (customer.address) {
          <div class="address">
            <h3>Address</h3>
            <p>{{ customer.address.street }}</p>
            <p>{{ customer.address.zipCode }} {{ customer.address.city }}</p>
          </div>
        }
      </div>
    } @else if (customerResource.resource.isLoading()) {
      <div class="loading">Loading customer data...</div>
    } @else if (customerResource.resource.error()) {
      <div class="error">Error loading customer</div>
    }
  `
})
export class CustomerViewComponent {
  customerResource = inject(CustomerResource);

  ngOnInit() {
    // Trigger customer load
    this.customerResource.params({
      customerId: 12345,
      eagerLoading: 2  // Load with moderate related data
    });
  }

  reloadCustomer() {
    this.customerResource.resource.reload();
  }
}

Tab-Based Customer Selection

import { Component, inject } from '@angular/core';
import { SelectedCustomerResource, CrmTabMetadataService } from '@isa/crm/data-access';
import { TabService } from '@isa/core/tabs';

@Component({
  selector: 'app-customer-tab',
  standalone: true,
  providers: [SelectedCustomerResource],
  template: `
    @if (selectedCustomerResource.resource.value(); as customer) {
      <div>
        <h2>Selected Customer</h2>
        <p>{{ customer.firstName }} {{ customer.lastName }}</p>
        <button (click)="clearSelection()">Clear Selection</button>
      </div>
    } @else {
      <div>No customer selected</div>
    }
  `
})
export class CustomerTabComponent {
  #tabService = inject(TabService);
  #crmTabMetadata = inject(CrmTabMetadataService);
  selectedCustomerResource = inject(SelectedCustomerResource);

  selectCustomer(customerId: number) {
    const tabId = this.#tabService.activatedTabId();
    if (tabId) {
      // Store in tab metadata - resource automatically reloads
      this.#crmTabMetadata.setSelectedCustomerId(tabId, customerId);
    }
  }

  clearSelection() {
    const tabId = this.#tabService.activatedTabId();
    if (tabId) {
      this.#crmTabMetadata.setSelectedCustomerId(tabId, undefined);
    }
  }
}

Paginated Shipping Addresses

import { Component, inject, signal } from '@angular/core';
import { ShippingAddressService, ShippingAddress } from '@isa/crm/data-access';

@Component({
  selector: 'app-shipping-addresses',
  template: '...'
})
export class ShippingAddressesComponent {
  #shippingAddressService = inject(ShippingAddressService);

  currentPage = signal(0);
  pageSize = signal(10);
  totalCount = signal(0);
  addresses = signal<ShippingAddress[]>([]);

  async loadPage(customerId: number, page: number): Promise<void> {
    const response = await this.#shippingAddressService.fetchCustomerShippingAddresses({
      customerId: customerId,
      take: this.pageSize(),
      skip: page * this.pageSize()
    });

    this.addresses.set(response.result);
    this.totalCount.set(response.totalCount);
    this.currentPage.set(page);

    console.log(`Loaded page ${page + 1} of ${this.totalPages()}`);
    console.log(`Showing ${response.result.length} of ${response.totalCount} addresses`);
  }

  totalPages(): number {
    return Math.ceil(this.totalCount() / this.pageSize());
  }

  nextPage(customerId: number): void {
    if (this.currentPage() < this.totalPages() - 1) {
      this.loadPage(customerId, this.currentPage() + 1);
    }
  }

  previousPage(customerId: number): void {
    if (this.currentPage() > 0) {
      this.loadPage(customerId, this.currentPage() - 1);
    }
  }
}

Using Resource for Shipping Addresses

import { Component, inject } from '@angular/core';
import { SelectedCustomerShippingAddressesResource } from '@isa/crm/data-access';

@Component({
  selector: 'app-customer-shipping-list',
  standalone: true,
  providers: [SelectedCustomerShippingAddressesResource],
  template: `
    @if (addressesResource.resource.value(); as addresses) {
      <div class="shipping-addresses">
        <h3>Shipping Addresses ({{ addresses.length }})</h3>
        @for (address of addresses; track address.id) {
          <div class="address-card">
            <h4>{{ address.firstName }} {{ address.lastName }}</h4>
            @if (address.address) {
              <p>{{ address.address.street }}</p>
              <p>{{ address.address.zipCode }} {{ address.address.city }}</p>
            }
            @if (address.validated) {
              <span class="validated">Validated</span>
            }
          </div>
        }
      </div>
    } @else if (addressesResource.resource.isLoading()) {
      <div>Loading addresses...</div>
    }
  `
})
export class CustomerShippingListComponent {
  addressesResource = inject(SelectedCustomerShippingAddressesResource);

  // Resource automatically loads based on selected customer in tab metadata
  // No manual parameter setting needed
}

Bonus Card Management

import { Component, inject } from '@angular/core';
import { CrmSearchService, getPrimaryBonusCard } from '@isa/crm/data-access';

@Component({
  selector: 'app-bonus-cards',
  template: '...'
})
export class BonusCardsComponent {
  #crmSearchService = inject(CrmSearchService);

  async loadBonusCards(customerId: number): Promise<void> {
    const response = await this.#crmSearchService.fetchCustomerCards({
      customerId: customerId
    });

    const cards = response.result;

    // Find primary card
    const primaryCard = getPrimaryBonusCard(cards);
    if (primaryCard) {
      console.log(`Primary Card: ${primaryCard.cardNumber}`);
      console.log(`Card Holder: ${primaryCard.firstName} ${primaryCard.lastName}`);
      console.log(`Total Points: ${primaryCard.totalPoints}`);
      console.log(`Bonus Value: ${primaryCard.bonusValue}`);
      console.log(`Active: ${primaryCard.isActive}`);

      if (primaryCard.isLocked) {
        console.warn('Card is locked!');
      }

      if (primaryCard.markedAsLost) {
        console.warn(`Card marked as lost on ${primaryCard.markedAsLost}`);
      }
    }

    // List all cards
    console.log(`Total cards: ${cards.length}`);
    cards.forEach((card, index) => {
      console.log(`Card ${index + 1}: ${card.cardNumber}`);
      console.log(`  Points: ${card.totalPoints}`);
      console.log(`  Payment enabled: ${card.isPaymentEnabled}`);
      if (card.validThrough) {
        console.log(`  Valid through: ${card.validThrough}`);
      }
    });
  }
}

Country Selection with Caching

import { Component, inject, signal } from '@angular/core';
import { CountryService, Country } from '@isa/crm/data-access';

@Component({
  selector: 'app-country-select',
  template: `
    <select [(ngModel)]="selectedCountry">
      @for (country of countries(); track country.id) {
        <option [value]="country.id">{{ country.name }}</option>
      }
    </select>
  `
})
export class CountrySelectComponent {
  #countryService = inject(CountryService);
  countries = signal<Country[]>([]);
  selectedCountry = signal<string>('');

  async ngOnInit() {
    // First call fetches from API
    const countries = await this.#countryService.getCountries();
    this.countries.set(countries);

    // Subsequent calls return cached result (instant)
    const cachedCountries = await this.#countryService.getCountries();
    console.log('Same result from cache:', countries === cachedCountries);
  }
}
import { Component, inject } from '@angular/core';
import { CountryResource } from '@isa/crm/data-access';

@Component({
  selector: 'app-country-dropdown',
  standalone: true,
  providers: [CountryResource],
  template: `
    @if (countryResource.resource.value(); as countries) {
      <select>
        @for (country of countries; track country.id) {
          <option [value]="country.id">{{ country.name }}</option>
        }
      </select>
    } @else if (countryResource.resource.isLoading()) {
      <div>Loading countries...</div>
    }
  `
})
export class CountryDropdownComponent {
  countryResource = inject(CountryResource);
  // Resource automatically loads on initialization
}

Request Cancellation

import { Component, inject } from '@angular/core';
import { CrmSearchService } from '@isa/crm/data-access';

@Component({
  selector: 'app-search',
  template: '...'
})
export class SearchComponent {
  #crmSearchService = inject(CrmSearchService);
  #currentRequest?: AbortController;

  async searchCustomer(customerId: number): Promise<void> {
    // Cancel previous request if still running
    this.#currentRequest?.abort();

    // Create new abort controller
    this.#currentRequest = new AbortController();

    try {
      const response = await this.#crmSearchService.fetchCustomer(
        { customerId, eagerLoading: 2 },
        this.#currentRequest.signal
      );

      console.log('Customer loaded:', response.result);
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Request cancelled');
      } else {
        console.error('Error loading customer:', error);
      }
    } finally {
      this.#currentRequest = undefined;
    }
  }

  cancelSearch(): void {
    this.#currentRequest?.abort();
  }
}

Handling Customer Name Formatting

import { Component, inject, signal } from '@angular/core';
import { CustomerFacade, getCustomerName } from '@isa/crm/data-access';

@Component({
  selector: 'app-customer-display',
  template: `
    <div>{{ displayName() }}</div>
  `
})
export class CustomerDisplayComponent {
  #customerFacade = inject(CustomerFacade);
  displayName = signal<string>('');

  async loadCustomer(customerId: number): Promise<void> {
    const customer = await this.#customerFacade.fetchCustomer({
      customerId: customerId
    });

    // Generate formatted name
    const name = getCustomerName(customer);
    this.displayName.set(name || 'Unknown Customer');

    // Helper handles edge cases:
    // - Missing first or last name
    // - Both names missing
    // - Extra whitespace trimming
  }
}

Resource Pattern

Understanding Angular Resources

Angular resources provide a reactive way to load asynchronous data with automatic state management:

// Resource definition
const customerResource = resource({
  params: () => this.#params(),  // Reactive parameters
  loader: async ({ params, abortSignal }) => {
    // Async data loading
    return await service.fetch(params, abortSignal);
  }
});

// Resource automatically provides:
// - value(): Current data value
// - isLoading(): Loading state
// - error(): Error state
// - status(): 'idle' | 'loading' | 'resolved' | 'error'
// - reload(): Manual refresh
// - hasValue(): True if data is available

Resource Lifecycle

  1. Initialization: Resource created with loader function
  2. Reactive Updates: When params signal changes, loader automatically re-runs
  3. Automatic Cancellation: Previous requests cancelled when new params trigger reload
  4. State Management: Loading, error, and data states automatically tracked
  5. Template Integration: Use signals in templates with @if control flow

Base vs Selected Resources

The library provides two patterns for each resource type:

Base Resources (manual parameter control):

// CustomerResource - manual control
customerResource.params({ customerId: 12345 });

Selected Resources (tab metadata integration):

// SelectedCustomerResource - automatic tab integration
// Automatically loads based on tab metadata
// No manual params() call needed

When to Use Each Pattern

Use Base Resources When:

  • Data loading is not tied to tab state
  • You need explicit control over parameters
  • Component is reusable across different contexts

Use Selected Resources When:

  • Data is tied to tab-based navigation
  • You want automatic synchronization with tab state
  • Component is used in tab-specific context

Tab Metadata Management

Metadata Storage

The library stores CRM-related IDs in tab metadata:

// Storage keys (namespaced)
const SELECTED_CUSTOMER_ID = 'crm-data-access.selectedCustomerId';
const SELECTED_SHIPPING_ADDRESS_ID = 'crm-data-access.selectedShippingAddressId';
const SELECTED_PAYER_ADDRESS_ID = 'crm-data-access.selectedPayerAddressId';

Metadata Lifecycle

  1. Set: Store ID when user selects entity
  2. Retrieve: Read ID when component needs data
  3. React: Resources automatically react to metadata changes
  4. Clear: Set to undefined when selection is cleared
  5. Persist: Metadata persists during tab navigation

Integration with Tab Service

// Get current tab ID
const tabId = tabService.activatedTabId();

// Store customer ID in tab
crmTabMetadataService.setSelectedCustomerId(tabId, 12345);

// Later, retrieve customer ID
const customerId = crmTabMetadataService.selectedCustomerId(tabId);

// Resources react automatically
@Component({
  providers: [SelectedCustomerResource]  // Auto-loads from tab metadata
})

Validation

All metadata values are validated using Zod schemas:

// Metadata is validated as optional number
getMetadataHelper(
  tabId,
  SELECTED_CUSTOMER_ID,
  z.number().optional(),  // Validation schema
  tabService.entityMap()
);

Validation with Zod

Input Validation

All service methods validate input parameters using Zod schemas:

// Schema definition
const FetchCustomerSchema = z.object({
  customerId: z.number().int(),      // Integer required
  eagerLoading: z.number().optional() // Optional number
});

// Automatic validation
const result = await service.fetchCustomer({
  customerId: '123',  // String coerced to number
  eagerLoading: 3
});
// Throws ZodError if validation fails

Type Coercion

Zod schemas support automatic type coercion:

// String to number coercion
customerId: z.number().int()  // '123' → 123

// Optional with default
eagerLoading: z.number().optional()  // undefined if not provided

// Nullable values
take: z.number().int().optional().nullable()  // Allows null or undefined

Validation Errors

Zod validation errors provide detailed information:

try {
  await service.fetchCustomer({ customerId: 'invalid' });
} catch (error) {
  if (error instanceof ZodError) {
    console.error('Validation errors:', error.errors);
    // error.errors contains array of validation issues
  }
}

Schema Types

The library exports both inferred types and input types:

// Inferred type (after validation)
type FetchCustomer = z.infer<typeof FetchCustomerSchema>;

// Input type (before validation, with coercion)
type FetchCustomerInput = z.input<typeof FetchCustomerSchema>;

// Use input type for function parameters
async fetchCustomer(params: FetchCustomerInput): Promise<...>

Error Handling

Error Types

ResponseArgsError

Thrown when the API returns an error:

import { ResponseArgsError } from '@isa/common/data-access';

try {
  await service.fetchCustomer({ customerId: 99999 });
} catch (error) {
  if (error instanceof ResponseArgsError) {
    console.error('API error:', error.message);
  }
}

ZodError

Thrown when input validation fails:

import { ZodError } from 'zod';

try {
  await service.fetchCustomer({ customerId: -1 });  // Negative ID invalid
} catch (error) {
  if (error instanceof ZodError) {
    console.error('Validation error:', error.errors);
  }
}

Service Error Handling

Services handle errors gracefully and return fallback values:

// CrmSearchService.fetchCustomer
try {
  const response = await firstValueFrom(req$);
  return response as ResponseArgs<Customer>;
} catch (error) {
  this.#logger.error('Error fetching customer', error);
  return undefined as unknown as ResponseArgs<Customer>;
}

Resource Error States

Resources automatically track error states:

@if (customerResource.resource.error(); as error) {
  <div class="error">
    Error loading customer: {{ error }}
  </div>
}

Request Cancellation

Use AbortSignal for request cancellation:

const controller = new AbortController();

// Start request
const promise = service.fetchCustomer(
  { customerId: 12345 },
  controller.signal
);

// Cancel if needed
controller.abort();

try {
  await promise;
} catch (error) {
  // Handle cancellation or other errors
  console.log('Request cancelled or failed');
}

Logging

All services include comprehensive logging:

// Service initialization
#logger = logger(() => ({
  service: 'CrmSearchService'
}));

// Operation logging
this.#logger.info('Fetching customer from API');
this.#logger.debug('Successfully fetched customer');
this.#logger.error('Error fetching customer', error);

Testing

The library uses Vitest with Angular Testing Utilities for testing.

Running Tests

# Run tests for this library
npx nx test crm-data-access --skip-nx-cache

# Run tests with coverage
npx nx test crm-data-access --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test crm-data-access --watch

Test Structure

The library includes comprehensive unit tests covering:

  • Service methods - Tests for all CrmSearchService, ShippingAddressService, CountryService methods
  • Validation - Tests Zod schema validation for all input types
  • Resources - Tests Angular resource behavior and state management
  • Tab metadata - Tests CrmTabMetadataService integration
  • Helper functions - Tests getPrimaryBonusCard, getCustomerName utilities
  • Error handling - Tests API errors, validation failures, abort signal behavior
  • Caching - Tests CountryService caching decorator

Example Test

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { getPrimaryBonusCard } from './get-primary-bonus-card.helper';
import { BonusCardInfo } from '../models';

describe('getPrimaryBonusCard', () => {
  it('should return the primary bonus card when one exists', () => {
    // Arrange
    const bonusCards: BonusCardInfo[] = [
      {
        firstName: 'John',
        lastName: 'Doe',
        isActive: true,
        isPrimary: false,
        totalPoints: 100,
      } as BonusCardInfo,
      {
        firstName: 'Jane',
        lastName: 'Smith',
        isActive: true,
        isPrimary: true,
        totalPoints: 200,
      } as BonusCardInfo,
    ];

    // Act
    const result = getPrimaryBonusCard(bonusCards);

    // Assert
    expect(result).toBeDefined();
    expect(result?.isPrimary).toBe(true);
    expect(result?.firstName).toBe('Jane');
  });

  it('should return undefined when no primary bonus card exists', () => {
    // Arrange
    const bonusCards: BonusCardInfo[] = [
      {
        firstName: 'John',
        lastName: 'Doe',
        isActive: true,
        isPrimary: false,
        totalPoints: 100,
      } as BonusCardInfo,
    ];

    // Act
    const result = getPrimaryBonusCard(bonusCards);

    // Assert
    expect(result).toBeUndefined();
  });
});

Architecture Notes

Current Architecture

The library follows a layered architecture:

Components/Features
       ↓
  Resources (Angular resource API)
       ↓
  Facades (optional pass-through)
       ↓
  Services (business logic)
       ↓
├─→ CrmSearchService
├─→ ShippingAddressService
├─→ CountryService
├─→ CrmTabMetadataService
       ↓
  Generated API Client (crm-api)
       ↓
  Zod Schemas (validation)

Design Patterns

Resource Pattern

The library uses Angular's resource API for reactive data loading:

Benefits:

  • Automatic loading state management
  • Built-in error handling
  • Automatic request cancellation
  • Reactive parameter updates
  • Template-friendly signals

Implementation:

resource({
  params: () => signal(),     // Reactive parameters
  loader: async ({ params, abortSignal }) => {
    return await service.fetch(params, abortSignal);
  }
})

Tab Metadata Pattern

Integration with tab service for context-aware data:

Benefits:

  • State persists during tab navigation
  • Automatic synchronization across components
  • Clean separation of concerns
  • Type-safe metadata storage

Implementation:

// Store in tab
crmTabMetadataService.setSelectedCustomerId(tabId, customerId);

// Resource automatically reacts to changes
effect(() => {
  const tabId = this.#tabId();
  const customerId = this.#customerMetadata.selectedCustomerId(tabId);
  this.params({ customerId });
});

Caching Pattern

CountryService uses decorator-based caching:

Benefits:

  • Reduces API calls
  • Improves performance
  • Transparent to consumers
  • Configurable TTL

Implementation:

@Cache()  // Decorator from @isa/common/decorators
async getCountries(abortSignal?: AbortSignal): Promise<Country[]> {
  // Method automatically cached
}

Known Architectural Considerations

1. Facade Evaluation (Low Priority)

The facades (CustomerFacade, CustomerCardsFacade) are simple pass-through wrappers:

Current State:

  • Minimal added value
  • Just delegate to services
  • CustomerFacade unwraps ResponseArgs

Recommendation:

  • Consider removal if not needed for future abstraction
  • Direct service injection is simpler
  • Keep if planning orchestration logic

Impact: Low risk, reduces one layer of indirection

2. Selected Resources vs Base Resources (Good Design)

The library provides both base and selected variants of resources:

Current State:

  • Base resources for manual control
  • Selected resources for tab integration
  • Clear separation of concerns

Strength:

  • Flexible usage patterns
  • Reusable across contexts
  • Clean abstraction

Impact: This is good architecture, no changes needed

3. Error Handling in Services (Medium Priority)

Services return fallback values on error instead of throwing:

Current State:

catch (error) {
  this.#logger.error('Error fetching customer', error);
  return undefined as unknown as ResponseArgs<Customer>;
}

Consideration:

  • Type assertion as unknown as ResponseArgs is not type-safe
  • Consumers don't know if undefined means error or not found
  • Errors are logged but not propagated

Alternative:

  • Throw errors and let consumers handle
  • Return discriminated union: { success: true, data } | { success: false, error }
  • Use Result/Either pattern

Impact: Medium - affects error handling patterns

4. Schema Organization (Good Structure)

Schemas are well-organized and comprehensive:

Strengths:

  • Clear separation by entity
  • Proper input/output types
  • Zod validation integration
  • Extends common schemas

Best Practice:

  • Maintains type safety throughout
  • Reusable schema composition

Performance Considerations

  1. Automatic Caching - CountryService caches results to minimize API calls
  2. Resource Cancellation - Resources automatically cancel pending requests
  3. Eager Loading Control - Configurable eager loading levels reduce over-fetching
  4. Pagination Support - Shipping addresses support pagination for large datasets
  5. Reactive Updates - Resources only reload when parameters change

Future Enhancements

Potential improvements identified:

  1. Batch Operations - Support batch customer/address lookups
  2. Offline Support - Cache customer data for offline access
  3. Search Functionality - Add customer search capabilities
  4. Mutation Operations - Add create/update/delete operations
  5. Optimistic Updates - Resource pattern with optimistic UI updates
  6. Error Recovery - Automatic retry logic for transient failures
  7. Analytics Integration - Track data access patterns

Dependencies

Required Libraries

  • @angular/core - Angular framework (20.1.2)
  • @generated/swagger/crm-api - Generated CRM API client
  • @isa/common/data-access - Common data access utilities (ResponseArgs, catchResponseArgsErrorPipe, takeUntilAborted)
  • @isa/common/decorators - Caching decorator (@Cache)
  • @isa/core/logging - Logging service
  • @isa/core/tabs - Tab service and metadata helpers
  • zod - Schema validation library
  • rxjs - Reactive programming (firstValueFrom)

Path Alias

Import from: @isa/crm/data-access

Generated API

The library depends on the generated CRM API client:

// Generated services used
import { CustomerService, ShippingAddressService, CountryService } from '@generated/swagger/crm-api';

// Generated types used
import { BonusCardInfoDTO, CountryDTO, PayerDTO } from '@generated/swagger/crm-api';

To regenerate API clients after backend changes:

npm run generate:swagger

License

Internal ISA Frontend library - not for external distribution.