- 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
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
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- Resource Pattern
- Tab Metadata Management
- Validation with Zod
- Error Handling
- Testing
- Architecture Notes
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 data2- Extended related data3- 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 parameterscustomerId: 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 parameterscustomerId: 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 parametersshippingAddressId: 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 IDcustomerId: 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 IDshippingAddressId: 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 IDpayerAddressId: 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 parametersabortSignal?: 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 parametersabortSignal?: 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 parameterscustomerId?: number- Customer IDeagerLoading?: 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 parameterscustomerId?: number- Customer IDtake?: number | null- Pagination: number of recordsskip?: 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 parametersshippingAddressId?: 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 parameterscustomerId?: 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);
}
}
Using CountryResource (Recommended)
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
- Initialization: Resource created with loader function
- Reactive Updates: When params signal changes, loader automatically re-runs
- Automatic Cancellation: Previous requests cancelled when new params trigger reload
- State Management: Loading, error, and data states automatically tracked
- 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
- Set: Store ID when user selects entity
- Retrieve: Read ID when component needs data
- React: Resources automatically react to metadata changes
- Clear: Set to undefined when selection is cleared
- 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 ResponseArgsis 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
- Automatic Caching - CountryService caches results to minimize API calls
- Resource Cancellation - Resources automatically cancel pending requests
- Eager Loading Control - Configurable eager loading levels reduce over-fetching
- Pagination Support - Shipping addresses support pagination for large datasets
- Reactive Updates - Resources only reload when parameters change
Future Enhancements
Potential improvements identified:
- Batch Operations - Support batch customer/address lookups
- Offline Support - Cache customer data for offline access
- Search Functionality - Add customer search capabilities
- Mutation Operations - Add create/update/delete operations
- Optimistic Updates - Resource pattern with optimistic UI updates
- Error Recovery - Automatic retry logic for transient failures
- 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 helperszod- Schema validation libraryrxjs- 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.