Files
ISA-Frontend/libs/crm/data-access/README.md
2025-11-25 14:13:44 +01:00

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 reactively
  • CustomerCardTransactionsResource - Loads loyalty card transaction history
  • CustomerShippingAddressesResource - Loads customer shipping addresses with pagination
  • CustomerShippingAddressResource - Loads a single shipping address
  • CustomerPayerAddressResource - Loads payer address details
  • CustomerBookingReasonsResource - Loads booking reason options
  • CustomerBonCheckResource - Validates Bon numbers reactively
  • PrimaryCustomerCardResource - Loads the primary bonus card for a customer
  • PayerResource - Loads payer details reactively
  • CountryResource - 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 input
  • isValidating: boolean - Validation in progress
  • isRedeeming: boolean - Redemption in progress
  • validationAttempted: boolean - Tracks if validation button was clicked
  • validatedBon: ValidatedBon | undefined - Validated Bon details
  • errorMessage: string | undefined - Error message

Computed Signals:

  • disableSearch: boolean - Whether search button should be disabled
  • disableRedemption: boolean - Whether redemption button should be disabled
  • hasValidBon: boolean - Whether a valid Bon is loaded
  • hasError: boolean - Whether there is an error

Methods:

  • setBonNumber(bonNumber: string) - Update Bon number input
  • setValidating(isValidating: boolean) - Set validation loading state
  • setRedeeming(isRedeeming: boolean) - Set redemption loading state
  • setValidationAttempted(attempted: boolean) - Mark validation attempt
  • setValidatedBon(bon: ValidatedBon | undefined) - Set validated Bon data
  • setError(errorMessage: string | undefined) - Set error message
  • reset() - 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:

  1. Returns undefined if no cards
  2. Filters for primary cards (where isPrimary === true)
  3. If primary cards exist, uses those; otherwise uses all cards
  4. Sorts alphabetically by code (case-insensitive)
  5. 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 === true
  • description is non-empty
  • group === 'd-customertype'

Priority (when multiple valid features):

  1. Features with key 'd-account'
  2. Features with key 'd-no-account'
  3. 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 Store
  • rxjs - Reactive programming
  • zod - Runtime schema validation

Best Practices

  1. Use Facades over Services for application code - Facades provide simplified APIs
  2. Use Resources for reactive data - Automatic race condition prevention and loading states
  3. Use Stores for complex UI state - Component-scoped state management
  4. Always validate inputs - All inputs are validated via Zod schemas
  5. Handle abort signals - Pass abort signals for cancellable operations
  6. Check ResponseArgs errors - Most methods return ResponseArgs<T> with .result and .error
  7. 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