24 KiB
@isa/crm/data-access
Data access layer for CRM (Customer Relationship Management) operations, providing services, facades, resources, and state management for customer, payer, bonus card, and shipping address management.
Overview
This library serves as the centralized data access layer for all CRM-related operations in the ISA-Frontend application. It provides a clean abstraction over the CRM API with:
- Facades: Simplified, high-level APIs for common CRM operations
- Services: Low-level API interactions with typed schemas and validation
- Resources: Angular Resource API implementations for reactive data loading
- Stores: NgRx Signal Store implementations for component-level state
- Schemas: Zod schemas for runtime type validation and type safety
- Helpers: Utility functions for CRM data manipulation
The library follows modern Angular patterns with:
- Standalone services with
providedIn: 'root' - Zod schema validation for all API inputs
- Resource API for automatic race condition prevention
- NgRx Signal Store for reactive state management
- Comprehensive logging via
@isa/core/logging
Installation
import {
// Facades
CustomerFacade,
CustomerCardsFacade,
CustomerCardBookingFacade,
CustomerBonRedemptionFacade,
// Services
CrmSearchService,
CountryService,
PayerService,
ShippingAddressService,
// Resources
CustomerResource,
SelectedCustomerResource,
CustomerBonusCardsResource,
CustomerShippingAddressesResource,
// Stores
BonRedemptionStore,
// Schemas & Types
Customer,
CrmPayer,
ShippingAddress,
BonusCardInfo,
// Helpers
getPrimaryBonusCard,
getEnabledCustomerFeature,
deduplicateAddressees,
// Constants
SELECTED_CUSTOMER_ID,
SELECTED_SHIPPING_ADDRESS_ID,
} from '@isa/crm/data-access';
Architecture
Facades
Facades provide simplified, business-focused APIs by combining multiple service calls and handling common patterns.
CustomerFacade
High-level API for customer operations.
@Component({...})
export class CustomerDetailComponent {
#customerFacade = inject(CustomerFacade);
async loadCustomer(customerId: number) {
const customer = await this.#customerFacade.fetchCustomer({
customerId,
eagerLoading: 3, // Load related data up to 3 levels deep
});
}
}
Methods:
fetchCustomer(params: FetchCustomerInput, abortSignal?: AbortSignal): Promise<Customer | undefined>
CustomerCardsFacade
Manages customer bonus cards (loyalty cards).
@Component({...})
export class BonusCardsComponent {
#cardsFacade = inject(CustomerCardsFacade);
async loadCards(customerId: number) {
const response = await this.#cardsFacade.get({ customerId });
return response.result; // BonusCardInfo[]
}
async addCard(customerId: number, cardCode: string) {
const result = await this.#cardsFacade.addCard({
customerId,
loyaltyCardValues: { cardCode },
});
}
async lockCard(cardCode: string) {
await this.#cardsFacade.lockCard({ cardCode });
}
async unlockCard(customerId: number, cardCode: string) {
await this.#cardsFacade.unlockCard({ customerId, cardCode });
}
}
Methods:
get(params: FetchCustomerCardsInput, abortSignal?: AbortSignal): Promise<ResponseArgs<BonusCardInfo[]>>addCard(params: AddCardInput): Promise<AccountDetailsDTO | undefined>lockCard(params: LockCardInput): Promise<boolean | undefined>unlockCard(params: UnlockCardInput): Promise<boolean | undefined>
CustomerCardBookingFacade
Manages loyalty card point bookings.
@Component({...})
export class BookingComponent {
#bookingFacade = inject(CustomerCardBookingFacade);
async init() {
// Load booking reasons (dropdown options)
const reasons = await this.#bookingFacade.fetchBookingReasons();
// Get current store for bookings
const store = await this.#bookingFacade.fetchCurrentBookingPartnerStore();
}
async addPoints(cardCode: string, points: number, reason: number) {
const result = await this.#bookingFacade.addBooking({
cardCode,
booking: { points, reason, storeId: '123' },
});
}
}
Methods:
fetchBookingReasons(abortSignal?: AbortSignal): Promise<KeyValueDTOOfStringAndInteger[]>fetchCurrentBookingPartnerStore(abortSignal?: AbortSignal): Promise<KeyValueDTOOfStringAndString | undefined>addBooking(params: AddBookingInput): Promise<LoyaltyBookingInfoDTO | undefined>
CustomerBonRedemptionFacade
Validates and redeems customer receipts (Bons) for loyalty points.
@Component({...})
export class BonRedemptionComponent {
#bonFacade = inject(CustomerBonRedemptionFacade);
async validateBon(cardCode: string, bonNumber: string) {
// Validates receipt and returns details
const response = await this.#bonFacade.checkBon({
cardCode,
bonNr: bonNumber,
storeId: '123', // Optional, auto-fetched if not provided
});
if (response.result) {
console.log('Bon date:', response.result.date);
console.log('Bon total:', response.result.total);
}
}
async redeemBon(cardCode: string, bonNumber: string) {
// Redeems receipt for points
const success = await this.#bonFacade.addBon({
cardCode,
bonNr: bonNumber,
storeId: '123', // Optional, auto-fetched if not provided
});
}
}
Methods:
checkBon(params: CheckBonInput, abortSignal?: AbortSignal): Promise<ResponseArgs<LoyaltyBonResponse>>addBon(params: AddBonInput): Promise<boolean>
Services
Low-level services that directly interact with the API.
CrmSearchService
Core service for CRM API operations with Zod validation and logging.
@Injectable({ providedIn: 'root' })
export class CrmSearchService {
async fetchCustomer(params: FetchCustomerInput, abortSignal?: AbortSignal): Promise<ResponseArgs<Customer>>
async fetchCustomerCards(params: FetchCustomerCardsInput, abortSignal?: AbortSignal): Promise<ResponseArgs<BonusCardInfo[]>>
async fetchLoyaltyBookings(customerId: number, abortSignal?: AbortSignal): Promise<LoyaltyBookingInfoDTO[]>
async fetchBookingReasons(abortSignal?: AbortSignal): Promise<KeyValueDTOOfStringAndInteger[]>
async fetchCurrentBookingPartnerStore(abortSignal?: AbortSignal): Promise<KeyValueDTOOfStringAndString | undefined>
async addCard(params: AddCardInput): Promise<AccountDetailsDTO | undefined>
async lockCard(params: LockCardInput): Promise<boolean | undefined>
async unlockCard(params: UnlockCardInput): Promise<boolean | undefined>
async addBooking(params: AddBookingInput): Promise<LoyaltyBookingInfoDTO | undefined>
async checkBon(params: CheckBonInput, abortSignal?: AbortSignal): Promise<ResponseArgs<LoyaltyBonResponse>>
async addBon(params: AddBonInput): Promise<boolean>
}
Features:
- All inputs validated with Zod schemas
- Automatic error catching via
catchResponseArgsErrorPipe() - Abort signal support for cancellation
- Comprehensive logging for debugging
- Method caching where appropriate (e.g.,
fetchCurrentBookingPartnerStore)
CountryService
Provides country list data.
@Component({...})
export class AddressFormComponent {
#countryService = inject(CountryService);
countries = signal<Country[]>([]);
async ngOnInit() {
this.countries.set(await this.#countryService.getCountries());
}
}
Methods:
getCountries(abortSignal?: AbortSignal): Promise<Country[]>- Cached with@Cache()decorator
PayerService
Manages payer (billing entity) data.
@Component({...})
export class PayerDetailComponent {
#payerService = inject(PayerService);
async loadPayer(payerId: number) {
const response = await this.#payerService.fetchPayer({ payerId });
return response.result; // CrmPayer
}
}
Methods:
fetchPayer(params: FetchPayerInput, abortSignal?: AbortSignal): Promise<ResponseArgs<CrmPayer>>
ShippingAddressService
Manages customer shipping addresses.
@Component({...})
export class ShippingAddressListComponent {
#shippingService = inject(ShippingAddressService);
async loadAddresses(customerId: number, page: number) {
const response = await this.#shippingService.fetchCustomerShippingAddresses({
customerId,
take: 20,
skip: page * 20,
});
console.log('Total:', response.totalCount);
console.log('Items:', response.result);
}
async loadSingleAddress(shippingAddressId: number) {
const response = await this.#shippingService.fetchShippingAddress({
shippingAddressId,
});
return response.result;
}
}
Methods:
fetchCustomerShippingAddresses(params: FetchCustomerShippingAddressesInput, abortSignal?: AbortSignal): Promise<ListResponseArgs<ShippingAddress>>fetchShippingAddress(params: FetchShippingAddressInput, abortSignal?: AbortSignal): Promise<ResponseArgs<ShippingAddress>>
CrmTabMetadataService
Manages CRM-related metadata for tab-based navigation.
@Component({...})
export class CustomerTabComponent {
#metadata = inject(CrmTabMetadataService);
#tabService = inject(TabService);
selectCustomer(customerId: number) {
const tabId = this.#tabService.activatedTabId();
if (tabId) {
this.#metadata.setSelectedCustomerId(tabId, customerId);
}
}
}
Resources
Angular Resource API implementations for reactive data loading with automatic race condition prevention.
CustomerResource
Base resource for loading customer data reactively.
@Component({
providers: [CustomerResource],
})
export class CustomerPanelComponent {
#resource = inject(CustomerResource);
customer = this.#resource.resource.value; // Signal<Customer | undefined>
isLoading = this.#resource.resource.isLoading; // Signal<boolean>
error = this.#resource.resource.error; // Signal<Error | undefined>
loadCustomer(customerId: number) {
this.#resource.params({ customerId, eagerLoading: 3 });
}
}
Usage Pattern:
- Inject via component providers (not root)
- Call
params()to trigger data loading - Access reactive signals:
value(),isLoading(),error()
SelectedCustomerResource
Automatically loads customer based on active tab's selected customer ID.
@Component({...})
export class CustomerDetailTabComponent {
#selectedCustomer = inject(SelectedCustomerResource);
// Automatically updates when tab changes or customer selection changes
customer = this.#selectedCustomer.resource.value;
isLoading = this.#selectedCustomer.resource.isLoading;
setEagerLoading(level: number) {
this.#selectedCustomer.setEagerLoading(level);
}
}
Features:
- Automatically syncs with
CrmTabMetadataService - Reacts to tab changes
- Provided in root (singleton)
Other Resources
CustomerBonusCardsResource- Loads customer bonus cards reactivelyCustomerCardTransactionsResource- Loads loyalty card transaction historyCustomerShippingAddressesResource- Loads customer shipping addresses with paginationCustomerShippingAddressResource- Loads a single shipping addressCustomerPayerAddressResource- Loads payer address detailsCustomerBookingReasonsResource- Loads booking reason optionsCustomerBonCheckResource- Validates Bon numbers reactivelyPrimaryCustomerCardResource- Loads the primary bonus card for a customerPayerResource- Loads payer details reactivelyCountryResource- Loads country list reactively
All resources follow the same pattern:
- Call
params()to set parameters and trigger loading - Access
resource.value()for data - Access
resource.isLoading()for loading state - Access
resource.error()for errors
Stores
NgRx Signal Store implementations for component-level state management.
BonRedemptionStore
Manages Bon redemption workflow state.
@Component({
providers: [BonRedemptionStore], // Component-scoped
})
export class BonRedemptionComponent {
store = inject(BonRedemptionStore);
// Signals (read)
bonNumber = this.store.bonNumber;
isValidating = this.store.isValidating;
isRedeeming = this.store.isRedeeming;
validatedBon = this.store.validatedBon;
errorMessage = this.store.errorMessage;
// Computed signals
disableSearch = this.store.disableSearch;
disableRedemption = this.store.disableRedemption;
hasValidBon = this.store.hasValidBon;
hasError = this.store.hasError;
// Methods (write)
updateBonNumber(value: string) {
this.store.setBonNumber(value);
}
async validateBon() {
this.store.setValidating(true);
this.store.setValidationAttempted(true);
try {
const result = await this.#bonFacade.checkBon({...});
this.store.setValidatedBon({
bonNumber: result.bonNr,
date: result.date,
total: result.total,
});
} catch (error) {
this.store.setError('Validation failed');
} finally {
this.store.setValidating(false);
}
}
reset() {
this.store.reset();
}
}
State:
bonNumber: string- Current Bon number inputisValidating: boolean- Validation in progressisRedeeming: boolean- Redemption in progressvalidationAttempted: boolean- Tracks if validation button was clickedvalidatedBon: ValidatedBon | undefined- Validated Bon detailserrorMessage: string | undefined- Error message
Computed Signals:
disableSearch: boolean- Whether search button should be disableddisableRedemption: boolean- Whether redemption button should be disabledhasValidBon: boolean- Whether a valid Bon is loadedhasError: boolean- Whether there is an error
Methods:
setBonNumber(bonNumber: string)- Update Bon number inputsetValidating(isValidating: boolean)- Set validation loading statesetRedeeming(isRedeeming: boolean)- Set redemption loading statesetValidationAttempted(attempted: boolean)- Mark validation attemptsetValidatedBon(bon: ValidatedBon | undefined)- Set validated Bon datasetError(errorMessage: string | undefined)- Set error messagereset()- Reset store to initial state
Schemas & Types
All input schemas use Zod for runtime validation.
Key Types
// Core entities
type Customer = z.infer<typeof CustomerSchema>;
type CrmPayer = z.infer<typeof PayerSchema>;
type ShippingAddress = z.infer<typeof ShippingAddressSchema>;
type BonusCardInfo = BonusCardInfoDTO & {
firstName: string;
lastName: string;
isActive: boolean;
isPrimary: boolean;
totalPoints: number;
};
// Input schemas
type FetchCustomerInput = z.infer<typeof FetchCustomerSchema>;
type FetchCustomerCardsInput = z.infer<typeof FetchCustomerCardsSchema>;
type AddCardInput = z.infer<typeof AddCardSchema>;
type LockCardInput = z.infer<typeof LockCardSchema>;
type UnlockCardInput = z.infer<typeof UnlockCardSchema>;
type AddBookingInput = z.infer<typeof AddBookingSchema>;
type CheckBonInput = z.infer<typeof CheckBonSchema>;
type AddBonInput = z.infer<typeof AddBonSchema>;
type FetchPayerInput = z.infer<typeof FetchPayerSchema>;
type FetchShippingAddressInput = z.infer<typeof FetchShippingAddressSchema>;
type FetchCustomerShippingAddressesInput = z.infer<typeof FetchCustomerShippingAddressesSchema>;
// Enums
enum CustomerType {
Private = 0,
Business = 1,
}
enum CustomerFeatureKey {
DAccount = 'd-account',
DNoAccount = 'd-no-account',
// ... more keys
}
enum CustomerFeatureGroup {
DCustomerType = 'd-customertype',
// ... more groups
}
Helpers
getPrimaryBonusCard
Retrieves the primary bonus card from a list of cards.
import { getPrimaryBonusCard } from '@isa/crm/data-access';
const cards: BonusCardInfo[] = [...];
const primaryCard = getPrimaryBonusCard(cards);
Logic:
- Returns
undefinedif no cards - Filters for primary cards (where
isPrimary === true) - If primary cards exist, uses those; otherwise uses all cards
- Sorts alphabetically by code (case-insensitive)
- Returns the first card from sorted list
getEnabledCustomerFeature
Retrieves the first enabled customer feature that meets validation criteria.
import { getEnabledCustomerFeature } from '@isa/crm/data-access';
const features: KeyValueOfStringAndString[] = customer.features;
const enabledFeature = getEnabledCustomerFeature(features);
Validation criteria:
enabled === truedescriptionis non-emptygroup === 'd-customertype'
Priority (when multiple valid features):
- Features with key
'd-account' - Features with key
'd-no-account' - First valid feature in array
deduplicateAddressees
Removes duplicate addressees from a list.
import { deduplicateAddressees, deduplicateBranches } from '@isa/crm/data-access';
const uniqueAddressees = deduplicateAddressees(addresseeList);
const uniqueBranches = deduplicateBranches(branchList);
Constants
export const SELECTED_CUSTOMER_ID = 'crm-data-access.selectedCustomerId';
export const SELECTED_SHIPPING_ADDRESS_ID = 'crm-data-access.selectedShippingAddressId';
export const SELECTED_PAYER_ADDRESS_ID = 'crm-data-access.selectedPayerAddressId';
Used as keys for storing/retrieving selected entity IDs in metadata services.
Usage Examples
Example 1: Customer Detail Page
@Component({
selector: 'app-customer-detail',
standalone: true,
template: `
@if (customer(); as customer) {
<div>{{ customer.firstName }} {{ customer.lastName }}</div>
<div>Customer #{{ customer.customerNumber }}</div>
} @else if (isLoading()) {
<loading-spinner />
} @else if (error()) {
<error-message [error]="error()" />
}
`,
})
export class CustomerDetailComponent {
#selectedCustomer = inject(SelectedCustomerResource);
customer = this.#selectedCustomer.resource.value;
isLoading = this.#selectedCustomer.resource.isLoading;
error = this.#selectedCustomer.resource.error;
ngOnInit() {
// Automatically loads based on active tab's selected customer
this.#selectedCustomer.setEagerLoading(3);
}
}
Example 2: Bonus Card Management
@Component({
selector: 'app-bonus-cards',
standalone: true,
providers: [CustomerBonusCardsResource],
})
export class BonusCardsComponent {
#cardsResource = inject(CustomerBonusCardsResource);
#cardsFacade = inject(CustomerCardsFacade);
cards = this.#cardsResource.resource.value;
primaryCard = computed(() => {
const cards = this.cards();
return cards ? getPrimaryBonusCard(cards) : undefined;
});
loadCards(customerId: number) {
this.#cardsResource.params({ customerId });
}
async lockCard(cardCode: string) {
try {
await this.#cardsFacade.lockCard({ cardCode });
// Refresh cards after locking
this.#cardsResource.reload();
} catch (error) {
console.error('Failed to lock card', error);
}
}
}
Example 3: Bon Redemption Workflow
@Component({
selector: 'app-bon-redemption',
standalone: true,
providers: [BonRedemptionStore],
template: `
<input
[(ngModel)]="bonNumber"
(ngModelChange)="store.setBonNumber($event)"
placeholder="Enter Bon number"
/>
<button
(click)="validateBon()"
[disabled]="store.disableSearch()"
>
Search
</button>
@if (store.hasValidBon(); as bon) {
<div>Date: {{ store.validatedBon()?.date }}</div>
<div>Total: {{ store.validatedBon()?.total }}</div>
<button
(click)="redeemBon()"
[disabled]="store.disableRedemption()"
>
Redeem
</button>
}
@if (store.hasError()) {
<error-message>{{ store.errorMessage() }}</error-message>
}
`,
})
export class BonRedemptionComponent {
store = inject(BonRedemptionStore);
#bonFacade = inject(CustomerBonRedemptionFacade);
@Input() cardCode!: string;
async validateBon() {
this.store.setValidating(true);
this.store.setValidationAttempted(true);
try {
const response = await this.#bonFacade.checkBon({
cardCode: this.cardCode,
bonNr: this.store.bonNumber(),
});
if (response.result) {
this.store.setValidatedBon({
bonNumber: response.result.bonNr,
date: response.result.date,
total: response.result.total,
});
}
} catch (error) {
this.store.setError('Bon validation failed');
} finally {
this.store.setValidating(false);
}
}
async redeemBon() {
this.store.setRedeeming(true);
try {
const success = await this.#bonFacade.addBon({
cardCode: this.cardCode,
bonNr: this.store.bonNumber(),
});
if (success) {
this.store.reset();
// Show success message
}
} catch (error) {
this.store.setError('Bon redemption failed');
} finally {
this.store.setRedeeming(false);
}
}
}
Example 4: Shipping Address Selection
@Component({
selector: 'app-shipping-address-list',
standalone: true,
providers: [CustomerShippingAddressesResource],
})
export class ShippingAddressListComponent {
#addressesResource = inject(CustomerShippingAddressesResource);
addresses = this.#addressesResource.resource.value;
isLoading = this.#addressesResource.resource.isLoading;
totalCount = computed(() => this.addresses()?.length ?? 0);
currentPage = signal(0);
pageSize = 20;
constructor() {
effect(() => {
const page = this.currentPage();
this.loadPage(page);
});
}
loadForCustomer(customerId: number) {
this.#addressesResource.params({
customerId,
take: this.pageSize,
skip: 0,
});
this.currentPage.set(0);
}
loadPage(page: number) {
this.#addressesResource.params({
skip: page * this.pageSize,
take: this.pageSize,
});
}
nextPage() {
this.currentPage.update(p => p + 1);
}
prevPage() {
this.currentPage.update(p => Math.max(0, p - 1));
}
}
Dependencies
This library depends on the following internal libraries:
@isa/common/data-access- Common data access utilities (ResponseArgs,catchResponseArgsErrorPipe,takeUntilAborted)@isa/common/decorators- Caching decorators (@Cache,CacheTimeToLive)@isa/core/logging- Logging infrastructure (logger)@isa/core/tabs- Tab management (TabService)@generated/swagger/crm-api- Generated CRM API clients
External dependencies:
@angular/core- Angular framework@ngrx/signals- NgRx Signal Storerxjs- Reactive programmingzod- Runtime schema validation
Best Practices
- Use Facades over Services for application code - Facades provide simplified APIs
- Use Resources for reactive data - Automatic race condition prevention and loading states
- Use Stores for complex UI state - Component-scoped state management
- Always validate inputs - All inputs are validated via Zod schemas
- Handle abort signals - Pass abort signals for cancellable operations
- Check ResponseArgs errors - Most methods return
ResponseArgs<T>with.resultand.error - Use computed signals - Derive state reactively rather than manually tracking
Testing
Mock implementations are provided for testing:
import { CustomerBonRedemptionFacadeMock } from '@isa/crm/data-access';
TestBed.configureTestingModule({
providers: [
{ provide: CustomerBonRedemptionFacade, useClass: CustomerBonRedemptionFacadeMock },
],
});
Run tests:
# Run tests for this library
nx test crm-data-access
# Run tests with coverage
nx test crm-data-access --code-coverage
# Run tests in watch mode
nx test crm-data-access --watch