From 731df8414dda7e5b7f1260c477b2b2ad48569245 Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Fri, 7 Nov 2025 12:18:31 +0000 Subject: [PATCH] Merged PR 2009: fix shopping cart - sync issues --- .../src/domain/checkout/checkout.service.ts | 610 +++++++++++++++++- .../checkout/store/domain-checkout.actions.ts | 5 + .../checkout/store/domain-checkout.reducer.ts | 518 +++++++++------ .../checkout-review.component.ts | 2 + .../process-bar-item.component.html | 2 +- .../process-bar-item.component.ts | 30 +- libs/checkout/data-access/src/index.ts | 1 + .../lib/resources/shopping-cart.resource.ts | 43 +- .../src/lib/services/shopping-cart.service.ts | 29 +- .../src/lib/shopping-cart.events.ts | 96 +++ 10 files changed, 1101 insertions(+), 235 deletions(-) create mode 100644 libs/checkout/data-access/src/lib/shopping-cart.events.ts diff --git a/apps/isa-app/src/domain/checkout/checkout.service.ts b/apps/isa-app/src/domain/checkout/checkout.service.ts index 2d34baa52..50fc8dc4e 100644 --- a/apps/isa-app/src/domain/checkout/checkout.service.ts +++ b/apps/isa-app/src/domain/checkout/checkout.service.ts @@ -72,12 +72,82 @@ import { ApplicationService } from '@core/application'; import { CustomerDTO } from '@generated/swagger/crm-api'; import { Config } from '@core/config'; import parseDuration from 'parse-duration'; -import { CheckoutMetadataService } from '@isa/checkout/data-access'; +import { + CheckoutMetadataService, + ShoppingCart, +} from '@isa/checkout/data-access'; +import { + ShoppingCartEvent, + ShoppingCartEvents, +} from '@isa/checkout/data-access'; +/** + * Domain service for managing the complete checkout flow including shopping cart operations, + * checkout creation, buyer/payer management, payment processing, and order completion. + * + * This service orchestrates interactions between: + * - NgRx Store for state management + * - Multiple Swagger API clients (checkout, OMS, shopping cart, payment, buyer, payer, branch, kulturpass) + * - Shopping cart event system for cross-component synchronization + * - Availability service for real-time product availability checks + * + * @remarks + * **Process ID Pattern**: All methods require a `processId` (typically `Date.now()`) to isolate + * checkout sessions. Multiple concurrent checkout processes can run independently. + * + * **Observable-First Design**: Most methods return Observables for reactive composition. Consumers + * should use RxJS operators for transformation and error handling. + * + * **Auto-Creation**: Shopping carts auto-create if missing. The service uses `filter()` operators + * to trigger lazy initialization and prevent race conditions. + * + * **Event Sourcing**: Publishes shopping cart events (Created, ItemAdded, ItemUpdated, ItemRemoved) + * for synchronization across components. Subscribes to events from `ShoppingCartService` to maintain + * consistency. + * + * **OLA Management**: Tracks Order Level Agreement (OLA) expiration timestamps per item and order type. + * Validates availability freshness before checkout completion. + * + * @example + * ```typescript + * // Basic shopping cart flow + * const processId = Date.now(); + * + * // Add items to cart + * this.checkoutService.addItemToShoppingCart({ + * processId, + * items: [{ productId: 123, quantity: 2 }] + * }).subscribe(); + * + * // Get cart (auto-creates if missing) + * this.checkoutService.getShoppingCart({ processId }) + * .subscribe(cart => console.log(cart)); + * + * // Complete checkout (orchestrates all steps) + * this.checkoutService.completeCheckout({ processId }) + * .subscribe(orders => console.log('Orders created:', orders)); + * ``` + * + * @see DomainCheckoutSelectors For state selection patterns + * @see DomainCheckoutActions For available actions + * @see ShoppingCartEvents For event system integration + */ @Injectable() export class DomainCheckoutService { + /** Metadata service for shopping cart persistence */ #checkoutMetadataService = inject(CheckoutMetadataService); + /** Event bus for shopping cart synchronization across components */ + #shoppingCartEvents = inject(ShoppingCartEvents); + + /** + * Gets the OLA (Order Level Agreement) expiration duration in milliseconds. + * + * OLA expiration defines how long availability data remains valid before requiring refresh. + * Default is 5 minutes if not configured. + * + * @returns Duration in milliseconds + */ get olaExpiration() { const exp = this._config.get('@domain/checkout.olaExpiration') ?? '5m'; return parseDuration(exp); @@ -96,9 +166,56 @@ export class DomainCheckoutService { private _payerService: StoreCheckoutPayerService, private _branchService: StoreCheckoutBranchService, private _kulturpassService: KulturPassService, - ) {} + ) { + // Subscribe to shopping cart events from ShoppingCartService + this.#shoppingCartEvents.events$ + .pipe( + // Only process events from ShoppingCartService to avoid circular updates + filter((payload) => payload.source === 'ShoppingCartService'), + ) + .subscribe((payload) => { + // Update the store with the shopping cart from the event + this.store.dispatch( + DomainCheckoutActions.setShoppingCartByShoppingCartId({ + shoppingCartId: payload.shoppingCart.id!, + shoppingCart: payload.shoppingCart as any, + }), + ); + }); + } //#region shoppingcart + /** + * Retrieves the shopping cart for a given process ID. Auto-creates the cart if it doesn't exist. + * + * @remarks + * **Auto-Creation**: If no cart exists for the process ID, triggers `createShoppingCart()` automatically. + * The Observable filters out null/undefined and waits for cart creation to complete. + * + * **Latest Data**: Setting `latest: true` forces a refresh from the API instead of using cached state. + * This is useful before critical operations like checkout completion. + * + * **Memoization**: Uses `@memorize()` decorator to cache results by parameters, reducing duplicate calls. + * + * @param params - Parameters object + * @param params.processId - Unique process identifier (typically `Date.now()`) + * @param params.latest - If true, fetches fresh data from API; if false/undefined, uses store state + * + * @returns Observable of the shopping cart DTO. Never emits null/undefined. + * + * @example + * ```typescript + * // Get cart from store (creates if missing) + * this.checkoutService.getShoppingCart({ processId: 123 }) + * .pipe(first()) + * .subscribe(cart => console.log('Items:', cart.items)); + * + * // Force refresh from API + * this.checkoutService.getShoppingCart({ processId: 123, latest: true }) + * .pipe(first()) + * .subscribe(cart => console.log('Fresh cart:', cart)); + * ``` + */ @memorize() getShoppingCart({ processId, @@ -143,6 +260,24 @@ export class DomainCheckoutService { ); } + /** + * Reloads the shopping cart from the API and updates the store with fresh data. + * + * This is an async method that fetches the latest cart state from the backend + * and dispatches an action to update the NgRx store. Unlike `getShoppingCart({ latest: true })`, + * this method doesn't return the cart Observable - it's fire-and-forget. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * + * @returns Promise that resolves when reload completes (or immediately if no cart exists) + * + * @example + * ```typescript + * await this.checkoutService.reloadShoppingCart({ processId: 123 }); + * console.log('Cart reloaded'); + * ``` + */ async reloadShoppingCart({ processId }: { processId: number }) { const shoppingCart = await firstValueFrom( this.store.select(DomainCheckoutSelectors.selectShoppingCartByProcessId, { @@ -166,6 +301,29 @@ export class DomainCheckoutService { ); } + /** + * Creates a new shopping cart and associates it with the given process ID. + * + * @remarks + * **State Updates**: + * - Saves shopping cart ID to metadata service for persistence + * - Publishes `ShoppingCartEvent.Created` event for component synchronization + * - Dispatches `setShoppingCart` action to update NgRx store + * + * **Auto-Invocation**: Usually called automatically by `getShoppingCart()` when no cart exists. + * Rarely needs to be called directly. + * + * @param params - Parameters object + * @param params.processId - Process identifier to associate with the new cart + * + * @returns Observable of the newly created shopping cart DTO + * + * @example + * ```typescript + * this.checkoutService.createShoppingCart({ processId: Date.now() }) + * .subscribe(cart => console.log('Cart created:', cart.id)); + * ``` + */ createShoppingCart({ processId, }: { @@ -180,6 +338,11 @@ export class DomainCheckoutService { processId, shoppingCart.id, ); + this.#shoppingCartEvents.pub( + ShoppingCartEvent.Created, + shoppingCart as ShoppingCart, + 'DomainCheckoutService', + ); this.store.dispatch( DomainCheckoutActions.setShoppingCart({ processId, @@ -190,6 +353,41 @@ export class DomainCheckoutService { ); } + /** + * Adds one or more items to the shopping cart. + * + * @remarks + * **Process Flow**: + * 1. Retrieves existing shopping cart (creates if missing) + * 2. Calls API to add items + * 3. Publishes `ShoppingCartEvent.ItemAdded` event + * 4. Updates NgRx store with modified cart + * + * **Validation**: Ensure items are validated via `canAddItem()` or `canAddItems()` before calling + * this method to avoid API errors. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.items - Array of items to add (product, quantity, availability, destination, etc.) + * + * @returns Observable of the updated shopping cart with new items + * + * @example + * ```typescript + * this.checkoutService.addItemToShoppingCart({ + * processId: 123, + * items: [{ + * product: { ean: '1234567890', catalogProductNumber: 456 }, + * quantity: 2, + * availability: availabilityDto, + * destination: destinationDto + * }] + * }).subscribe(cart => console.log('Items:', cart.items.length)); + * ``` + * + * @see canAddItem For single item validation + * @see canAddItems For bulk item validation + */ addItemToShoppingCart({ processId, items, @@ -208,6 +406,11 @@ export class DomainCheckoutService { .pipe( map((response) => response.result), tap((shoppingCart) => { + this.#shoppingCartEvents.pub( + ShoppingCartEvent.ItemAdded, + shoppingCart as ShoppingCart, + 'DomainCheckoutService', + ); this.store.dispatch( DomainCheckoutActions.setShoppingCart({ processId, @@ -267,6 +470,37 @@ export class DomainCheckoutService { ); } + /** + * Validates if a single item can be added to the shopping cart. + * + * Checks business rules, customer features, and cart compatibility before adding items. + * Use this before calling `addItemToShoppingCart()` to prevent API errors. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.availability - OLA availability data for the item + * @param params.orderType - Order type (e.g., 'Abholung', 'Versand', 'Download', 'Rücklage') + * + * @returns Observable of `true` if item can be added, or error message string if not allowed + * + * @example + * ```typescript + * this.checkoutService.canAddItem({ + * processId: 123, + * availability: olaAvailability, + * orderType: 'Versand' + * }).subscribe(result => { + * if (result === true) { + * // Proceed with adding item + * this.checkoutService.addItemToShoppingCart(...); + * } else { + * console.error('Cannot add item:', result); + * } + * }); + * ``` + * + * @see canAddItems For bulk validation + */ canAddItem({ processId, availability, @@ -312,6 +546,38 @@ export class DomainCheckoutService { .pipe(map((response) => response?.result)); } + /** + * Validates if multiple items can be added to the shopping cart in bulk. + * + * More efficient than calling `canAddItem()` multiple times. Returns validation + * results for each item in the payload. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.payload - Array of item payloads to validate + * @param params.orderType - Order type for all items + * + * @returns Observable array of validation results (one per item). Each result contains + * `ok` flag and optional error messages. + * + * @example + * ```typescript + * this.checkoutService.canAddItems({ + * processId: 123, + * payload: [ + * { availabilities: [avail1], productId: 111, quantity: 2 }, + * { availabilities: [avail2], productId: 222, quantity: 1 } + * ], + * orderType: 'Versand' + * }).subscribe(results => { + * results.forEach((result, index) => { + * console.log(`Item ${index}:`, result.ok ? 'Valid' : result.message); + * }); + * }); + * ``` + * + * @see canAddItem For single item validation + */ canAddItems({ processId, payload, @@ -383,6 +649,50 @@ export class DomainCheckoutService { ); } + /** + * Updates an existing item in the shopping cart (quantity, availability, special comment, etc.). + * + * @remarks + * **Special Behavior**: + * - Setting `quantity: 0` removes the item and publishes `ItemRemoved` event instead of `ItemUpdated` + * - Always fetches latest cart state (`latest: true`) to avoid stale data conflicts + * - If availability is updated, adds timestamp to history for OLA validation + * + * **Event Publishing**: + * - Publishes `ShoppingCartEvent.ItemRemoved` if quantity is 0 + * - Publishes `ShoppingCartEvent.ItemUpdated` for all other changes + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.shoppingCartItemId - ID of the cart item to update + * @param params.update - Fields to update (quantity, availability, specialComment, etc.) + * + * @returns Observable of the updated shopping cart + * + * @example + * ```typescript + * // Update quantity + * this.checkoutService.updateItemInShoppingCart({ + * processId: 123, + * shoppingCartItemId: 456, + * update: { quantity: 5 } + * }).subscribe(); + * + * // Remove item (quantity = 0) + * this.checkoutService.updateItemInShoppingCart({ + * processId: 123, + * shoppingCartItemId: 456, + * update: { quantity: 0 } + * }).subscribe(); + * + * // Update availability + * this.checkoutService.updateItemInShoppingCart({ + * processId: 123, + * shoppingCartItemId: 456, + * update: { availability: newAvailabilityDto } + * }).subscribe(); + * ``` + */ updateItemInShoppingCart({ processId, shoppingCartItemId, @@ -404,6 +714,17 @@ export class DomainCheckoutService { .pipe( map((response) => response.result), tap((shoppingCart) => { + // Check if item was removed (quantity === 0) + const eventType = + update.quantity === 0 + ? ShoppingCartEvent.ItemRemoved + : ShoppingCartEvent.ItemUpdated; + this.#shoppingCartEvents.pub( + eventType, + shoppingCart as ShoppingCart, + 'DomainCheckoutService', + ); + this.store.dispatch( DomainCheckoutActions.setShoppingCart({ processId, @@ -432,6 +753,34 @@ export class DomainCheckoutService { //#region Checkout + /** + * Retrieves the checkout entity for a given process. Auto-creates if missing. + * + * @remarks + * **Auto-Creation**: Similar to `getShoppingCart()`, automatically triggers `createCheckout()` + * if no checkout exists for the process ID. + * + * **Refresh**: Setting `refresh: true` forces recreation of the checkout entity from the API. + * + * **Purpose**: The checkout entity aggregates buyer, payer, payment, destinations, and + * notification channels. It's required before order completion. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.refresh - If true, recreates checkout from API; if false/undefined, uses store state + * + * @returns Observable of the checkout DTO. Never emits null/undefined. + * + * @example + * ```typescript + * this.checkoutService.getCheckout({ processId: 123 }) + * .pipe(first()) + * .subscribe(checkout => { + * console.log('Buyer:', checkout.buyer); + * console.log('Payment:', checkout.payment); + * }); + * ``` + */ getCheckout({ processId, refresh, @@ -772,6 +1121,40 @@ export class DomainCheckoutService { ); } + /** + * Refreshes the availability data for a single shopping cart item. + * + * Fetches fresh availability from the appropriate service based on order type + * (Abholung, Rücklage, Download, Versand, DIG-Versand, B2B-Versand) and updates + * the cart item. + * + * @remarks + * **Order Type Handling**: + * - **Abholung** (Pickup): Requires branch for availability check + * - **Rücklage** (TakeAway): Requires branch for availability check + * - **Download**: No additional parameters needed + * - **Versand** (Delivery): Standard delivery availability + * - **DIG-Versand** (Digital Delivery): Digital goods delivery + * - **B2B-Versand** (B2B Delivery): Business customer delivery + * + * **Updates**: Automatically calls `updateItemInShoppingCart()` with the new availability + * after fetching. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.shoppingCartItemId - ID of the cart item to refresh + * + * @returns Promise of the refreshed availability DTO, or undefined if item not found + * + * @example + * ```typescript + * const availability = await this.checkoutService.refreshAvailability({ + * processId: 123, + * shoppingCartItemId: 456 + * }); + * console.log('Updated availability:', availability); + * ``` + */ async refreshAvailability({ processId, shoppingCartItemId, @@ -876,9 +1259,41 @@ export class DomainCheckoutService { } /** - * Check if the availability of all items is valid - * @param param0 Process Id - * @returns true if the availability of all items is valid + * Validates if all shopping cart items have fresh availability data (OLA not expired). + * + * OLA (Order Level Agreement) defines how long availability data remains valid. + * This method polls the store at regular intervals and checks if the oldest + * availability timestamp is still within the expiration window. + * + * @remarks + * **Polling**: Uses `rxjsInterval()` to continuously check OLA status. The Observable emits + * `true` while all items are valid, `false` when any item expires. + * + * **Timestamp Tracking**: Tracks timestamps per `${itemId}_${orderType}` combination. + * Shipping types (Versand, DIG-Versand, B2B-Versand) fall back to generic 'Versand' timestamp. + * + * **Default Interval**: Polls every `olaExpiration / 10` milliseconds (default: 30 seconds for 5-minute expiration). + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.interval - Custom polling interval in milliseconds (optional) + * + * @returns Observable that emits `true` when OLA is valid, `false` when expired. + * Emits only on changes (`distinctUntilChanged()`). + * + * @example + * ```typescript + * this.checkoutService.validateOlaStatus({ processId: 123 }) + * .subscribe(isValid => { + * if (!isValid) { + * console.warn('Availability data expired! Refresh required.'); + * // Trigger availability refresh + * } + * }); + * ``` + * + * @see olaExpiration For expiration duration configuration + * @see checkoutIsValid For combined OLA + availability validation */ validateOlaStatus({ processId, @@ -965,6 +1380,39 @@ export class DomainCheckoutService { ); } + /** + * Validates if the checkout is ready for order completion. + * + * Combines OLA status validation with availability validation. Both must be true + * for checkout to proceed. + * + * @remarks + * **Validation Checks**: + * - OLA Status: All item availabilities are within expiration window + * - Availabilities: All items are marked as available (not out of stock) + * + * **Polling**: Uses fast polling (250ms) for OLA status to catch expiration quickly. + * + * @param params - Parameters object + * @param params.processId - Process identifier + * + * @returns Observable that emits `true` when checkout is valid, `false` otherwise. + * Emits on every change in either validation. + * + * @example + * ```typescript + * this.checkoutService.checkoutIsValid({ processId: 123 }) + * .subscribe(isValid => { + * this.checkoutButton.disabled = !isValid; + * if (!isValid) { + * this.showWarning('Checkout unavailable: Availability expired'); + * } + * }); + * ``` + * + * @see validateOlaStatus For OLA-only validation + * @see validateAvailabilities For availability-only validation + */ checkoutIsValid({ processId }: { processId: number }): Observable { const olaStatus$ = this.validateOlaStatus({ processId, interval: 250 }); @@ -975,6 +1423,73 @@ export class DomainCheckoutService { ); } + /** + * Orchestrates the complete checkout process from cart validation to order creation. + * + * This is the most complex method in the service, executing a 12-step sequence that + * validates data, updates entities, and creates orders. Each step must complete before + * the next begins. + * + * @remarks + * **Execution Sequence** (sequential, not parallel): + * 1. **Update Destination**: Sets shipping addresses on delivery destinations + * 2. **Refresh Shopping Cart**: Gets latest cart state from API + * 3. **Set Special Comments**: Applies agent comments to all cart items + * 4. **Refresh Checkout**: Recreates checkout entity from current state + * 5. **Check Availabilities**: Validates download items are available + * 6. **Update Availabilities**: Refreshes DIG-Versand and B2B-Versand prices + * 7. **Set Buyer**: Submits buyer information to checkout + * 8. **Set Notification Channels**: Configures email/SMS preferences + * 9. **Set Payer**: Submits payer information (if needed for order type) + * 10. **Set Payment Type**: Configures payment method (Rechnung/Bar) + * 11. **Set Destination**: Updates destinations with shipping addresses + * 12. **Complete Order**: Submits to OMS for order creation + * + * **Payment Type Logic**: + * - Download/Versand/DIG-Versand/B2B-Versand → Payment type 128 (Rechnung/Invoice) + * - Pickup/TakeAway only → Payment type 4 (Bar/Cash) + * + * **Payer Requirement**: + * - Required for B2B customers or Download/Delivery order types + * - Skipped for in-store pickup/takeaway only + * + * **Error Handling**: + * - HTTP 409 (Conflict): Order already exists - dispatches existing orders to store + * - Other errors propagate to consumer for handling + * - Failed availability checks throw error preventing order creation + * + * **Side Effects**: + * - Logs each step to console (for debugging) + * - Updates NgRx store at multiple points + * - Dispatches final orders to store on success + * + * @param params - Parameters object + * @param params.processId - Process identifier + * + * @returns Observable array of created orders (DisplayOrderDTO[]) + * + * @throws Observable error if availability validation fails or API returns non-409 error + * + * @example + * ```typescript + * this.checkoutService.completeCheckout({ processId: 123 }) + * .subscribe({ + * next: (orders) => { + * console.log('Orders created:', orders); + * this.router.navigate(['/order-confirmation']); + * }, + * error: (error) => { + * if (error.status === 409) { + * console.log('Order already exists'); + * } else { + * console.error('Checkout failed:', error); + * } + * } + * }); + * ``` + * + * @see completeKulturpassOrder For Kulturpass-specific checkout flow + */ completeCheckout({ processId, }: { @@ -1328,11 +1843,45 @@ export class DomainCheckoutService { //#region Common - // Fix für Ticket #4619 Versand Artikel im Warenkob -> keine Änderung bei Kundendaten erfassen - // Auskommentiert, da dieser Aufruf oftmals mit gleichen Parametern aufgerufen wird (ohne ausgewählten Kunden nur ein leeres Objekt bei customerFeatures) - // memorize macht keinen deepCompare von Objekten und denkt hier, dass immer der gleiche Return Wert zurückkommt, allerdings ist das hier oft nicht der Fall - // und der Decorator memorized dann fälschlicherweise - // @memorize() + /** + * Validates if a customer can be set on the shopping cart based on cart contents and customer features. + * + * @remarks + * **Memoization Disabled**: The `@memorize()` decorator was intentionally disabled for this method + * due to shallow comparison issues. The decorator couldn't detect when `customerFeatures` object + * changed, causing stale cached results. See Ticket #4619. + * + * **Response Fields**: + * - `ok`: True if customer can be set without issues + * - `filter`: Customer search filters (e.g., `{ customertype: 'webshop;guest' }`) + * - `message`: Error message if validation fails + * - `create`: Options for creating new customer types (store, guest, webshop, b2b) + * + * **Use Cases**: + * - Determine which customer types are compatible with cart contents + * - Pre-filter customer search results + * - Enable/disable customer type creation buttons + * + * @param params - Parameters object + * @param params.processId - Process identifier + * @param params.customerFeatures - Customer feature flags (optional: webshop, guest, b2b, staff, etc.) + * + * @returns Observable with validation result containing ok, filter, message, and create options + * + * @example + * ```typescript + * this.checkoutService.canSetCustomer({ + * processId: 123, + * customerFeatures: { webshop: 'true', guest: 'false' } + * }).subscribe(result => { + * if (result.ok) { + * console.log('Customer types allowed:', result.create.options.values); + * } else { + * console.error('Cannot set customer:', result.message); + * } + * }); + * ``` + */ canSetCustomer({ processId, customerFeatures, @@ -1467,6 +2016,32 @@ export class DomainCheckoutService { ); } + /** + * Retrieves all active, online branches that support shipping. + * + * @remarks + * **Filtering**: Returns only branches matching ALL criteria: + * - `status === 1` (Active) + * - `branchType === 1` (Standard branch type) + * - `isOnline === true` (Available online) + * - `isShippingEnabled === true` (Supports shipping) + * + * **Memoization**: Uses `@memorize()` decorator to cache results. Subsequent calls + * return cached data without API round-trip. + * + * **Pagination**: Fetches up to 999 branches (effectively all branches). + * + * @returns Observable array of filtered branch DTOs + * + * @example + * ```typescript + * this.checkoutService.getBranches() + * .subscribe(branches => { + * console.log('Available branches:', branches.length); + * this.branchDropdown.options = branches; + * }); + * ``` + */ @memorize() getBranches(): Observable { return this._branchService @@ -1547,6 +2122,21 @@ export class DomainCheckoutService { ); } + /** + * Removes all checkout data for a process ID from the store. + * + * Cleans up shopping cart, checkout entity, buyer, payer, and all associated data. + * Call this when a checkout session is complete or cancelled. + * + * @param params - Parameters object + * @param params.processId - Process identifier to remove + * + * @example + * ```typescript + * // After successful order or when user cancels + * this.checkoutService.removeProcess({ processId: 123 }); + * ``` + */ removeProcess({ processId }: { processId: number }) { this.store.dispatch( DomainCheckoutActions.removeCheckoutWithProcessId({ processId }), diff --git a/apps/isa-app/src/domain/checkout/store/domain-checkout.actions.ts b/apps/isa-app/src/domain/checkout/store/domain-checkout.actions.ts index 68988fee0..ed39abe5e 100644 --- a/apps/isa-app/src/domain/checkout/store/domain-checkout.actions.ts +++ b/apps/isa-app/src/domain/checkout/store/domain-checkout.actions.ts @@ -19,6 +19,11 @@ export const setShoppingCart = createAction( props<{ processId: number; shoppingCart: ShoppingCartDTO }>(), ); +export const setShoppingCartByShoppingCartId = createAction( + `${prefix} Set Shopping Cart By Shopping Cart Id`, + props<{ shoppingCartId: number; shoppingCart: ShoppingCartDTO }>(), +); + export const setCheckout = createAction( `${prefix} Set Checkout`, props<{ processId: number; checkout: CheckoutDTO }>(), diff --git a/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts b/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts index d2351ac85..7b0a027b1 100644 --- a/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts +++ b/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts @@ -1,207 +1,311 @@ -import { createReducer, on } from '@ngrx/store'; -import { initialCheckoutState, storeCheckoutAdapter } from './domain-checkout.state'; - -import * as DomainCheckoutActions from './domain-checkout.actions'; -import { Dictionary } from '@ngrx/entity'; -import { CheckoutEntity } from './defs/checkout.entity'; -import { isNullOrUndefined } from '@utils/common'; - -const _domainCheckoutReducer = createReducer( - initialCheckoutState, - on(DomainCheckoutActions.setShoppingCart, (s, { processId, shoppingCart }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - - const addedShoppingCartItems = - shoppingCart?.items - ?.filter((item) => !entity.shoppingCart?.items?.find((i) => i.id === item.id)) - ?.map((item) => item.data) ?? []; - - entity.shoppingCart = shoppingCart; - - entity.itemAvailabilityTimestamp = entity.itemAvailabilityTimestamp ? { ...entity.itemAvailabilityTimestamp } : {}; - - const now = Date.now(); - - for (let shoppingCartItem of addedShoppingCartItems) { - if (shoppingCartItem.features?.orderType) { - entity.itemAvailabilityTimestamp[`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`] = now; - } - } - - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setCheckout, (s, { processId, checkout }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.checkout = checkout; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setBuyerCommunicationDetails, (s, { processId, email, mobile }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - const communicationDetails = { ...entity.buyer.communicationDetails }; - communicationDetails.email = email || communicationDetails.email; - communicationDetails.mobile = mobile || communicationDetails.mobile; - entity.buyer = { - ...entity.buyer, - communicationDetails, - }; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setNotificationChannels, (s, { processId, notificationChannels }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - return storeCheckoutAdapter.setOne({ ...entity, notificationChannels }, s); - }), - on(DomainCheckoutActions.setCheckoutDestination, (s, { processId, destination }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.checkout = { - ...entity.checkout, - destinations: entity.checkout.destinations.map((dest) => { - if (dest.id === destination.id) { - return { ...dest, ...destination }; - } - return { ...dest }; - }), - }; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setShippingAddress, (s, { processId, shippingAddress }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.shippingAddress = shippingAddress; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setBuyer, (s, { processId, buyer }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.buyer = buyer; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setPayer, (s, { processId, payer }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.payer = payer; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setSpecialComment, (s, { processId, agentComment }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.specialComment = agentComment; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.removeCheckoutWithProcessId, (s, { processId }) => { - return storeCheckoutAdapter.removeOne(processId, s); - }), - on(DomainCheckoutActions.setOrders, (s, { orders }) => ({ ...s, orders: [...s.orders, ...orders] })), - on(DomainCheckoutActions.updateOrderItem, (s, { item }) => { - const orders = [...s.orders]; - - const orderToUpdate = orders?.find((order) => order.items?.find((i) => i.id === item?.id)); - const orderToUpdateIndex = orders?.indexOf(orderToUpdate); - - const orderItemToUpdate = orderToUpdate?.items?.find((i) => i.id === item?.id); - const orderItemToUpdateIndex = orderToUpdate?.items?.indexOf(orderItemToUpdate); - - const items = [...orderToUpdate?.items]; - items[orderItemToUpdateIndex] = item; - - orders[orderToUpdateIndex] = { - ...orderToUpdate, - items: [...items], - }; - - return { ...s, orders: [...orders] }; - }), - on(DomainCheckoutActions.removeAllOrders, (s) => ({ - ...s, - orders: [], - })), - on(DomainCheckoutActions.setOlaError, (s, { processId, olaErrorIds }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.olaErrorIds = olaErrorIds; - return storeCheckoutAdapter.setOne(entity, s); - }), - on(DomainCheckoutActions.setCustomer, (s, { processId, customer }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - entity.customer = customer; - return storeCheckoutAdapter.setOne(entity, s); - }), - on( - DomainCheckoutActions.addShoppingCartItemAvailabilityToHistory, - (s, { processId, shoppingCartItemId, availability }) => { - const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities }); - - const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp - ? { ...entity?.itemAvailabilityTimestamp } - : {}; - - const item = entity?.shoppingCart?.items?.find((i) => i.id === shoppingCartItemId)?.data; - - if (!item?.features?.orderType) return s; - - itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = Date.now(); - - entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp; - - return storeCheckoutAdapter.setOne(entity, s); - }, - ), - on( - DomainCheckoutActions.addShoppingCartItemAvailabilityToHistoryByShoppingCartId, - (s, { shoppingCartId, shoppingCartItemId, availability }) => { - const entity = getCheckoutEntityByShoppingCartId({ shoppingCartId, entities: s.entities }); - - const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp - ? { ...entity?.itemAvailabilityTimestamp } - : {}; - - const item = entity?.shoppingCart?.items?.find((i) => i.id === shoppingCartItemId)?.data; - - if (!item?.features?.orderType) return s; - - itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = Date.now(); - - entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp; - - return storeCheckoutAdapter.setOne(entity, s); - }, - ), -); - -export function domainCheckoutReducer(state, action) { - return _domainCheckoutReducer(state, action); -} - -function getOrCreateCheckoutEntity({ - entities, - processId, -}: { - entities: Dictionary; - processId: number; -}): CheckoutEntity { - let entity = entities[processId]; - - if (isNullOrUndefined(entity)) { - return { - processId, - checkout: undefined, - shoppingCart: undefined, - shippingAddress: undefined, - orders: [], - payer: undefined, - buyer: undefined, - specialComment: '', - notificationChannels: 0, - olaErrorIds: [], - customer: undefined, - // availabilityHistory: [], - itemAvailabilityTimestamp: {}, - }; - } - - return { ...entity }; -} - -function getCheckoutEntityByShoppingCartId({ - entities, - shoppingCartId, -}: { - entities: Dictionary; - shoppingCartId: number; -}): CheckoutEntity { - return Object.values(entities).find((entity) => entity.shoppingCart?.id === shoppingCartId); -} +import { createReducer, on } from '@ngrx/store'; +import { + initialCheckoutState, + storeCheckoutAdapter, +} from './domain-checkout.state'; + +import * as DomainCheckoutActions from './domain-checkout.actions'; +import { Dictionary } from '@ngrx/entity'; +import { CheckoutEntity } from './defs/checkout.entity'; +import { isNullOrUndefined } from '@utils/common'; + +const _domainCheckoutReducer = createReducer( + initialCheckoutState, + on( + DomainCheckoutActions.setShoppingCart, + (s, { processId, shoppingCart }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + + const addedShoppingCartItems = + shoppingCart?.items + ?.filter( + (item) => + !entity.shoppingCart?.items?.find((i) => i.id === item.id), + ) + ?.map((item) => item.data) ?? []; + + entity.shoppingCart = shoppingCart; + + entity.itemAvailabilityTimestamp = entity.itemAvailabilityTimestamp + ? { ...entity.itemAvailabilityTimestamp } + : {}; + + const now = Date.now(); + + for (let shoppingCartItem of addedShoppingCartItems) { + if (shoppingCartItem.features?.orderType) { + entity.itemAvailabilityTimestamp[ + `${shoppingCartItem.id}_${shoppingCartItem.features.orderType}` + ] = now; + } + } + + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on( + DomainCheckoutActions.setShoppingCartByShoppingCartId, + (s, { shoppingCartId, shoppingCart }) => { + let entity = getCheckoutEntityByShoppingCartId({ + shoppingCartId, + entities: s.entities, + }); + + if (!entity) { + // No entity found for this shoppingCartId, cannot update + return s; + } + + entity = { ...entity, shoppingCart }; + + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on(DomainCheckoutActions.setCheckout, (s, { processId, checkout }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.checkout = checkout; + return storeCheckoutAdapter.setOne(entity, s); + }), + on( + DomainCheckoutActions.setBuyerCommunicationDetails, + (s, { processId, email, mobile }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + const communicationDetails = { ...entity.buyer.communicationDetails }; + communicationDetails.email = email || communicationDetails.email; + communicationDetails.mobile = mobile || communicationDetails.mobile; + entity.buyer = { + ...entity.buyer, + communicationDetails, + }; + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on( + DomainCheckoutActions.setNotificationChannels, + (s, { processId, notificationChannels }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + return storeCheckoutAdapter.setOne( + { ...entity, notificationChannels }, + s, + ); + }, + ), + on( + DomainCheckoutActions.setCheckoutDestination, + (s, { processId, destination }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.checkout = { + ...entity.checkout, + destinations: entity.checkout.destinations.map((dest) => { + if (dest.id === destination.id) { + return { ...dest, ...destination }; + } + return { ...dest }; + }), + }; + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on( + DomainCheckoutActions.setShippingAddress, + (s, { processId, shippingAddress }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.shippingAddress = shippingAddress; + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on(DomainCheckoutActions.setBuyer, (s, { processId, buyer }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.buyer = buyer; + return storeCheckoutAdapter.setOne(entity, s); + }), + on(DomainCheckoutActions.setPayer, (s, { processId, payer }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.payer = payer; + return storeCheckoutAdapter.setOne(entity, s); + }), + on( + DomainCheckoutActions.setSpecialComment, + (s, { processId, agentComment }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.specialComment = agentComment; + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on(DomainCheckoutActions.removeCheckoutWithProcessId, (s, { processId }) => { + return storeCheckoutAdapter.removeOne(processId, s); + }), + on(DomainCheckoutActions.setOrders, (s, { orders }) => ({ + ...s, + orders: [...s.orders, ...orders], + })), + on(DomainCheckoutActions.updateOrderItem, (s, { item }) => { + const orders = [...s.orders]; + + const orderToUpdate = orders?.find((order) => + order.items?.find((i) => i.id === item?.id), + ); + const orderToUpdateIndex = orders?.indexOf(orderToUpdate); + + const orderItemToUpdate = orderToUpdate?.items?.find( + (i) => i.id === item?.id, + ); + const orderItemToUpdateIndex = + orderToUpdate?.items?.indexOf(orderItemToUpdate); + + const items = [...(orderToUpdate?.items ?? [])]; + items[orderItemToUpdateIndex] = item; + + orders[orderToUpdateIndex] = { + ...orderToUpdate, + items: [...items], + }; + + return { ...s, orders: [...orders] }; + }), + on(DomainCheckoutActions.removeAllOrders, (s) => ({ + ...s, + orders: [], + })), + on(DomainCheckoutActions.setOlaError, (s, { processId, olaErrorIds }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.olaErrorIds = olaErrorIds; + return storeCheckoutAdapter.setOne(entity, s); + }), + on(DomainCheckoutActions.setCustomer, (s, { processId, customer }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + entity.customer = customer; + return storeCheckoutAdapter.setOne(entity, s); + }), + on( + DomainCheckoutActions.addShoppingCartItemAvailabilityToHistory, + (s, { processId, shoppingCartItemId, availability }) => { + const entity = getOrCreateCheckoutEntity({ + processId, + entities: s.entities, + }); + + const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp + ? { ...entity?.itemAvailabilityTimestamp } + : {}; + + const item = entity?.shoppingCart?.items?.find( + (i) => i.id === shoppingCartItemId, + )?.data; + + if (!item?.features?.orderType) return s; + + itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = + Date.now(); + + entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp; + + return storeCheckoutAdapter.setOne(entity, s); + }, + ), + on( + DomainCheckoutActions.addShoppingCartItemAvailabilityToHistoryByShoppingCartId, + (s, { shoppingCartId, shoppingCartItemId, availability }) => { + const entity = getCheckoutEntityByShoppingCartId({ + shoppingCartId, + entities: s.entities, + }); + + const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp + ? { ...entity?.itemAvailabilityTimestamp } + : {}; + + const item = entity?.shoppingCart?.items?.find( + (i) => i.id === shoppingCartItemId, + )?.data; + + if (!item?.features?.orderType) return s; + + itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = + Date.now(); + + entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp; + + return storeCheckoutAdapter.setOne(entity, s); + }, + ), +); + +export function domainCheckoutReducer(state, action) { + return _domainCheckoutReducer(state, action); +} + +function getOrCreateCheckoutEntity({ + entities, + processId, +}: { + entities: Dictionary; + processId: number; +}): CheckoutEntity { + let entity = entities[processId]; + + if (isNullOrUndefined(entity)) { + return { + processId, + checkout: undefined, + shoppingCart: undefined, + shippingAddress: undefined, + orders: [], + payer: undefined, + buyer: undefined, + specialComment: '', + notificationChannels: 0, + olaErrorIds: [], + customer: undefined, + // availabilityHistory: [], + itemAvailabilityTimestamp: {}, + }; + } + + return { ...entity }; +} + +function getCheckoutEntityByShoppingCartId({ + entities, + shoppingCartId, +}: { + entities: Dictionary; + shoppingCartId: number; +}): CheckoutEntity { + return Object.values(entities).find( + (entity) => entity.shoppingCart?.id === shoppingCartId, + ); +} diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts index a15763063..95928b31b 100644 --- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts +++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts @@ -638,6 +638,8 @@ export class CheckoutReviewComponent this.#checkoutService.reloadShoppingCart({ processId: this.applicationService.activatedProcessId, }); + + this.refreshAvailabilities(); } async changeAddress() { diff --git a/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.html b/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.html index d7a0f9169..e08e32ce6 100644 --- a/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.html +++ b/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.html @@ -21,7 +21,7 @@ (click)="$event?.preventDefault(); $event?.stopPropagation()" > - {{ cartCount() }} + {{ itemCount() }} } diff --git a/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.ts b/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.ts index d282d6426..917f282cb 100644 --- a/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.ts +++ b/apps/isa-app/src/shared/shell/process-bar/process-bar-item/process-bar-item.component.ts @@ -11,6 +11,8 @@ import { inject, computed, input, + effect, + untracked, } from '@angular/core'; import { Router } from '@angular/router'; import { ApplicationProcess, ApplicationService } from '@core/application'; @@ -44,6 +46,7 @@ export class ShellProcessBarItemComponent { #tabService = inject(TabService); #checkoutMetadataService = inject(CheckoutMetadataService); + #shoppingCartResource = inject(ShoppingCartResource); tab = computed(() => this.#tabService.entityMap()[this.process().id]); @@ -51,6 +54,18 @@ export class ShellProcessBarItemComponent return this.#checkoutMetadataService.getShoppingCartId(this.process().id); }); + shoppingCartIdEffect = effect(() => { + const shoppingCartId = this.shoppingCartId(); + untracked(() => + this.#shoppingCartResource.setShoppingCartId(shoppingCartId), + ); + }); + + itemCount = computed(() => { + const shoppingCart = this.#shoppingCartResource.resource.value(); + return shoppingCart?.items?.length ?? 0; + }); + private _process$ = new BehaviorSubject(undefined); process$ = this._process$.asObservable(); @@ -61,22 +76,7 @@ export class ShellProcessBarItemComponent closed = new EventEmitter(); showCart = computed(() => { - // TODO: Nach Prämie release kann die Logik wieder rein return true; - const tab = this.tab(); - - const pdata = tab.metadata?.process_data as { count?: number }; - - if (!pdata) { - return false; - } - - return 'count' in pdata; - }); - - cartCount = computed(() => { - // TODO: Use implementation from develop - return 0; }); currentLocationUrlTree = computed(() => { diff --git a/libs/checkout/data-access/src/index.ts b/libs/checkout/data-access/src/index.ts index e50f311a4..2aefbcf74 100644 --- a/libs/checkout/data-access/src/index.ts +++ b/libs/checkout/data-access/src/index.ts @@ -7,3 +7,4 @@ export * from './lib/store'; export * from './lib/helpers'; export * from './lib/services'; export * from './lib/resources'; +export * from './lib/shopping-cart.events'; diff --git a/libs/checkout/data-access/src/lib/resources/shopping-cart.resource.ts b/libs/checkout/data-access/src/lib/resources/shopping-cart.resource.ts index 0866c785f..85355f6da 100644 --- a/libs/checkout/data-access/src/lib/resources/shopping-cart.resource.ts +++ b/libs/checkout/data-access/src/lib/resources/shopping-cart.resource.ts @@ -1,14 +1,55 @@ -import { effect, inject, Injectable, resource, signal } from '@angular/core'; +import { + effect, + inject, + Injectable, + resource, + signal, + untracked, +} from '@angular/core'; import { TabService } from '@isa/core/tabs'; import { CheckoutMetadataService } from '../services'; import { ShoppingCartService } from '../services'; +import { ShoppingCartEvents } from '../shopping-cart.events'; +import { isEqual } from 'lodash'; @Injectable() export class ShoppingCartResource { #shoppingCartService = inject(ShoppingCartService); + #shoppingCartEvents = inject(ShoppingCartEvents).events; #params = signal(undefined); + constructor() { + effect(() => { + const event = this.#shoppingCartEvents(); + const id = untracked(() => this.#params()); + const value = untracked(() => this.resource.value()); + + if (id === undefined) { + return; + } + + if (!event) { + return; + } + + if (isEqual(value, event.shoppingCart)) { + return; + } + + if (event.shoppingCart.id !== id) { + return; + } + + this.resource.set(event.shoppingCart); + console.log('ShoppingCartResource updated from event', { + event: event.event, + shoppingCartId: event.shoppingCart.id, + source: event.source, + }); + }); + } + setShoppingCartId(shoppingCartId: number | undefined) { this.#params.set(shoppingCartId); diff --git a/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts b/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts index 2eeaa7331..35877aff0 100644 --- a/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts +++ b/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts @@ -26,10 +26,12 @@ import { firstValueFrom } from 'rxjs'; import { logger } from '@isa/core/logging'; import { CheckoutMetadataService } from './checkout-metadata.service'; import { getItemKey } from '../helpers'; +import { ShoppingCartEvent, ShoppingCartEvents } from '../shopping-cart.events'; @Injectable({ providedIn: 'root' }) export class ShoppingCartService { #logger = logger(() => ({ service: 'ShoppingCartService' })); + #shoppingCartStream = inject(ShoppingCartEvents); #storeCheckoutShoppingCartService = inject(StoreCheckoutShoppingCartService); #checkoutMetadataService = inject(CheckoutMetadataService); @@ -45,6 +47,11 @@ export class ShoppingCartService { throw err; } + this.#shoppingCartStream.pub( + ShoppingCartEvent.Created, + res.result as ShoppingCart, + 'ShoppingCartService', + ); return res.result as ShoppingCart; } @@ -70,6 +77,12 @@ export class ShoppingCartService { this.#logger.error('Failed to fetch shopping cart', err); throw err; } + + this.#shoppingCartStream.pub( + ShoppingCartEvent.ItemUpdated, + res.result as ShoppingCart, + 'ShoppingCartService', + ); return res.result as ShoppingCart; } @@ -118,13 +131,17 @@ export class ShoppingCartService { throw err; } + this.#shoppingCartStream.pub( + ShoppingCartEvent.ItemAdded, + res.result as ShoppingCart, + 'ShoppingCartService', + ); return res.result as ShoppingCart; } async updateItem( params: UpdateShoppingCartItemParams, ): Promise { - console.log('UpdateShoppingCartItemParams', params); const parsed = UpdateShoppingCartItemParamsSchema.parse(params); const req$ = @@ -144,6 +161,11 @@ export class ShoppingCartService { throw err; } + this.#shoppingCartStream.pub( + ShoppingCartEvent.ItemUpdated, + res.result as ShoppingCart, + 'ShoppingCartService', + ); return res.result as ShoppingCart; } @@ -170,6 +192,11 @@ export class ShoppingCartService { throw err; } + this.#shoppingCartStream.pub( + ShoppingCartEvent.ItemRemoved, + res.result as ShoppingCart, + 'ShoppingCartService', + ); return res.result as ShoppingCart; } diff --git a/libs/checkout/data-access/src/lib/shopping-cart.events.ts b/libs/checkout/data-access/src/lib/shopping-cart.events.ts new file mode 100644 index 000000000..d0721b644 --- /dev/null +++ b/libs/checkout/data-access/src/lib/shopping-cart.events.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { filter, Observable, Subject } from 'rxjs'; +import { logger } from '@isa/core/logging'; +import { ShoppingCart } from './models'; +import { isEqual } from 'lodash'; + +export const ShoppingCartEvent = { + Created: 'ShoppingCartCreated', + ItemAdded: 'ShoppingCartItemAdded', + ItemRemoved: 'ShoppingCartItemRemoved', + ItemUpdated: 'ShoppingCartItemUpdated', + Completed: 'ShoppingCartCompleted', +} as const; + +export type ShoppingCartEvent = + (typeof ShoppingCartEvent)[keyof typeof ShoppingCartEvent]; + +export type ShoppingCartEventSource = + | 'ShoppingCartService' + | 'DomainCheckoutService'; + +export interface ShoppingCartEventPayload { + event: ShoppingCartEvent; + shoppingCart: ShoppingCart; + source: ShoppingCartEventSource; +} + +// TODO: Replace with a centralized event-bus system in the future +// This class currently uses RxJS Subject for event management, but should be +// migrated to a unified event-bus architecture that can handle events across +// the entire application (not just shopping cart events). +// Consider using a pattern like: +// - Global EventBus service with typed event channels +// - Event namespacing (e.g., 'checkout.cart.itemAdded') +// - Better event replay/history capabilities +// - Integration with NgRx effects or similar state management patterns +@Injectable({ providedIn: 'root' }) +export class ShoppingCartEvents { + #logger = logger(() => ({ service: 'ShoppingCartEvents' })); + #subject = new Subject(); + + #lastEvent: ShoppingCartEventPayload | null = null; + + readonly events$ = this.#subject.asObservable(); + + readonly events = toSignal(this.events$); + + pub( + event: ShoppingCartEvent, + shoppingCart: ShoppingCart, + source: ShoppingCartEventSource, + ): void { + this.#logger.info('Publishing shopping cart event', () => ({ + event, + shoppingCartId: shoppingCart.id, + source, + itemCount: shoppingCart.items?.length ?? 0, + })); + + if (isEqual(this.#lastEvent?.shoppingCart, shoppingCart)) { + this.#logger.debug('Duplicate event detected, skipping publish', () => ({ + event, + shoppingCartId: shoppingCart.id, + source, + })); + return; + } + + this.#subject.next({ event, shoppingCart, source }); + this.#lastEvent = { event, shoppingCart, source }; + } + + sub( + event: ShoppingCartEvent | ShoppingCartEvent[], + shoppingCartId: number, + source?: ShoppingCartEventSource, + ): Observable { + const coercedEvents = Array.isArray(event) ? event : [event]; + + this.#logger.debug('Subscribing to shopping cart events', () => ({ + events: coercedEvents, + shoppingCartId, + source: source ?? 'any', + })); + + return this.events$.pipe( + filter( + (payload) => + payload.shoppingCart.id === shoppingCartId && + coercedEvents.includes(payload.event) && + (source === undefined || payload.source === source), + ), + ); + } +}