Compare commits

...

15 Commits

Author SHA1 Message Date
Nino
c643d988fa Merge branch 'release/4.3' 2025-11-11 21:56:00 +01:00
Nino
463e46e17a chore(azure-pipelines, package-lock): Version Bump 2025-11-11 16:41:29 +01:00
Nino
c98d5666a4 fix(checkout-completion-orchestrator): Added Feedback Error Dialog 2025-11-11 16:07:50 +01:00
Nino
835546a799 feat(reward-purchasing-options): Disable Delivery Options 2025-11-11 15:18:38 +01:00
Lorenz Hilpert
f261fc9987 Merged PR 2021: feat(pickup-shelf): display Prämie label and Lesepunkte for reward items
feat(pickup-shelf): display Prämie label and Lesepunkte for reward items

- Add "Prämie" ui-label badge below product images in both list and details views
- Display Lesepunkte value instead of price for reward items
- Update getOrderItemRewardFeature helper to use structural typing for better type flexibility
- Apply to pickup-shelf-details-item and pickup-shelf-list-item components

Fixes #5467
2025-11-11 14:15:41 +00:00
Lorenz Hilpert
cc186dbbe2 Merged PR 2022: fix(checkout): prevent duplicate tasks in open reward carousel
Related work items: #5468
2025-11-11 14:15:05 +00:00
Nino Righi
6df02d9e86 Merged PR 2020: feat(confirmation-list-item-action-card): improve action card visibility logi...
feat(confirmation-list-item-action-card): improve action card visibility logic and add ordered state

Enhance the action card display logic to show only when both feature flag
and command/completion conditions are met. Add support for "Ordered" state
to distinguish pending items from completed ones.

Changes:
- Add hasLoyaltyCollectCommand helper to check for LOYALTY_COLLECT_COMMAND
- Update displayActionCard logic to require both Rücklage feature AND
  (loyalty collect command OR completion state)
- Add ProcessingStatusState.Ordered to distinguish ordered vs completed items
- Update isComplete to exclude ordered items from completion state
- Move role-based visibility check to outer container level
- Remove unused CSS class for completed state
- Add comprehensive unit tests for new helpers

The action card now correctly appears only for items that need user action,
hiding for CallCenter role and for items without the required commands.

Ref: #5459
2025-11-11 12:16:17 +00:00
Lorenz Hilpert
4a7b74a6c5 Merged PR 2018: add reward points (Prämie) display and label
Related work items: #5413
2025-11-11 09:48:26 +00:00
Lorenz Hilpert
9c989055cb Merged PR 2019: fix(customer-details): prioritize cart navigation over reward return URL
fix(customer-details): prioritize cart navigation over reward return URL

Fixes issue #5461 where navigating to cart after customer selection would
incorrectly route to reward shop page. Changed navigation priority to check
for regular shopping cart items before checking for reward return URL context.

This ensures that active standard checkout flows take precedence over any
lingering reward flow navigation context.

Related work items: #5461
2025-11-11 09:09:29 +00:00
Lorenz Hilpert
2e0853c91a Merged PR 2016: feat(core/auth): add type-safe role-based authorization library
feat(core/auth): add type-safe role-based authorization library

Created @isa/core/auth library with comprehensive role checking:
- RoleService for programmatic hasRole() checks
- IfRoleDirective for declarative *ifRole/*ifNotRole templates
- Type-safe Role enum (CallCenter, Store)
- TokenProvider abstraction with OAuth2 integration
- Signal-based reactive rendering with Angular effects
- Zero-configuration setup via InjectionToken factory

Fixed Bug #5451:
- Hide action buttons for HSC (CallCenter) users on reward order confirmation
- Applied *ifNotRole="Role.CallCenter" to actions container
- Actions now hidden while maintaining card visibility

Testing:
- 18/18 unit tests passing with Vitest
- JUnit and Cobertura reporting configured
- Complete test coverage for role checking logic

Documentation:
- Comprehensive README (817 lines) with API reference
- Usage examples and architecture diagrams
- Updated library-reference.md (62→63 libraries)

Technical:
- Handles both string and array JWT role formats
- Integrated with @isa/core/logging
- Standalone directive (no module imports)
- Full TypeScript type safety

Closes #5451

Related work items: #5451
2025-11-10 17:00:39 +00:00
Nino
c5ea5ed3ec Merge branch 'develop' of https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend into develop 2025-11-10 16:16:21 +01:00
Nino
7c29429040 fix(get-main-actions): Return only enabled Actions 2025-11-10 16:15:41 +01:00
Nino Righi
c3e9a03169 Merged PR 2015: fix(crm-data-access, customer-details, reward-shopping-cart): persist selecte...
fix(crm-data-access, customer-details, reward-shopping-cart): persist selected addresses across navigation flows

Implement address selection persistence using CRM tab metadata to ensure
selected shipping addresses and payers are retained throughout the customer
selection flow, particularly when navigating from Kundenkarte to reward cart.

Changes include:
- Create PayerResource and CustomerPayerAddressResource to load selected
  payer from tab metadata with fallback to customer as payer
- Create PayerService to fetch payer data from CRM API with proper error
  handling and abort signal support
- Update BillingAndShippingAddressCardComponent to prefer selected addresses
  from metadata over customer defaults, with computed loading state
- Refactor continue() flow in CustomerDetailsViewMainComponent to load
  selected addresses from metadata before setting in checkout service
- Add adapter logic to convert CRM payer/shipping address types to checkout
  types with proper type casting for incompatible enum types
- Implement fallback chain: metadata selection → component state → customer
  default for both payer and shipping address

This ensures address selections made in the address selection dialogs are
properly preserved and applied when completing the customer selection flow,
fixing the issue where addresses would revert to customer defaults.

Ref: #5411
2025-11-10 15:10:56 +00:00
Lorenz Hilpert
3c13a230cc Merged PR 1992: ♻️ refactor(catalog): extract shared Reihe prefix pattern to constants
♻️ refactor(catalog): extract shared Reihe prefix pattern to constants

Ref: #5421

- Create reihe.constants.ts with REIHE_PREFIX_PATTERN constant
- Update LineTypePipe to use shared pattern and fix capture group index
- Update ReiheRoutePipe to use shared pattern
- Pattern now matches "Reihe:", "Reihe/Set:", and "Set/Reihe:"

Related work items: #5421
2025-11-03 10:07:02 +00:00
Lorenz Hilpert
0a5b1dac71 Merged PR 1983: fix(auth): prevent duplicate login popup on slow networks during QR code login
fix(auth): prevent duplicate login popup on slow networks during QR code login

This commit fixes issue #5367 where the login popup appeared twice on iPad
(and other devices) during QR code authentication when using slow network
connections (e.g., Fast 4G).

Root Cause:
During the QR code login flow on slow networks, there was a race condition:
1. User scans QR code and login flow initiates
2. Before SSO redirect completes, HTTP requests (e.g., user storage) fail with 401
3. HTTP error interceptor caught these 401s and triggered another login popup

Changes:

1. HTTP Error Interceptor (http-error.interceptor.ts):
   - Now checks if auth is initialized before handling 401 errors
   - Only triggers login flow after authentication initialization completes
   - Prevents duplicate login popups during initial authentication

2. User Storage Provider (user.storage-provider.ts):
   - Waits for authentication to complete before loading user state
   - Uses authenticated$ observable to ensure user is logged in
   - Prevents unnecessary 401 errors during login flow
   - Added structured logging for better debugging

3. Auth Service (auth.service.ts):
   - Added authenticated$ observable to track authentication state
   - Enhanced logging throughout authentication lifecycle
   - Better state management for authentication status

4. App Module (app.module.ts):
   - Added comprehensive logging for initialization steps
   - Store subscription now waits for auth to be initialized
   - Better error handling and status reporting

5. Storage Tokens (tokens.ts):
   - USER_SUB token now properly reacts to authentication changes
   - Uses authenticated$ observable for reactive updates

Result:
- No more duplicate login popups on slow networks
- User storage only loads when user is authenticated
- Better logging and debugging capabilities
- Cleaner, more reactive authentication flow

Related work items: #5367
2025-10-24 14:31:32 +00:00
74 changed files with 41309 additions and 38826 deletions

View File

@@ -68,7 +68,7 @@ import {
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { debounceTime, firstValueFrom } from 'rxjs';
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import {
@@ -77,7 +77,7 @@ import {
LogLevel,
withSink,
ConsoleLogSink,
logger,
logger as loggerFactory,
} from '@isa/core/logging';
import {
IDBStorageProvider,
@@ -85,57 +85,74 @@ import {
UserStorageProvider,
} from '@isa/core/storage';
import { Store } from '@ngrx/store';
import { TabNavigationService } from '@isa/core/tabs';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
export function _appInitializerFactory(config: Config, injector: Injector) {
return async () => {
// Get logging service for initialization logging
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
try {
logger.info('Starting application initialization');
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
if (!online) {
logger.warn('Waiting for network connection');
statusElement.innerHTML =
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
logger.info('Network connection established');
statusElement.innerHTML = 'Konfigurationen werden geladen...';
logger.info('Loading configurations');
statusElement.innerHTML = 'Scanner wird initialisiert...';
logger.info('Initializing scanner');
const scanAdapter = injector.get(ScanAdapterService);
await scanAdapter.init();
logger.info('Scanner initialized');
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
logger.info('Initializing authentication');
const auth = injector.get(AuthService);
try {
await auth.init();
} catch {
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
logger.info('Performing login');
const strategy = injector.get(LoginStrategy);
await strategy.login();
return;
}
statusElement.innerHTML = 'Native Container wird initialisiert...';
logger.info('Initializing native container');
const nativeContainer = injector.get(NativeContainerService);
await nativeContainer.init();
logger.info('Native container initialized');
statusElement.innerHTML = 'Datenbank wird initialisiert...';
logger.info('Initializing database');
await injector.get(IDBStorageProvider).init();
logger.info('Database initialized');
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
logger.info('Loading user storage');
const userStorage = injector.get(UserStorageProvider);
await userStorage.init();
@@ -144,16 +161,29 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
const state = userStorage.get('store');
if (state && state['version'] === version) {
store.dispatch({ type: 'HYDRATE', payload: userStorage.get('store') });
logger.info('Store hydrated from user storage');
} else {
logger.debug('Store hydration skipped', () => ({
reason: state ? 'version mismatch' : 'no stored state',
}));
}
// Subscribe on Store changes and save to user storage
store.pipe(debounceTime(1000)).subscribe((state) => {
userStorage.set('store', { ...state, version });
});
auth.initialized$
.pipe(
filter((initialized) => initialized),
switchMap(() => store.pipe(debounceTime(1000))),
)
.subscribe((state) => {
userStorage.set('store', state);
});
logger.info('Application initialization completed');
// Inject tab navigation service to initialize it
injector.get(TabNavigationService).init();
} catch (error) {
console.error('Error during app initialization', error);
logger.error('Application initialization failed', error as Error, () => ({
message: (error as Error).message,
}));
laoderElement.remove();
statusElement.classList.add('text-xl');
statusElement.innerHTML +=
@@ -199,7 +229,7 @@ export function _notificationsHubOptionsFactory(
}
const USER_SUB_FACTORY = () => {
const _logger = logger(() => ({
const _logger = loggerFactory(() => ({
context: 'USER_SUB',
}));
const auth = inject(OAuthService);

View File

@@ -8,24 +8,25 @@ import {
} from '@angular/common/http';
import { from, NEVER, Observable, throwError } from 'rxjs';
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
import { LoginStrategy } from '@core/auth';
import { IsaLogProvider } from '../providers';
import { LogLevel } from '@core/logger';
import { AuthService, LoginStrategy } from '@core/auth';
import { injectOnline$ } from '../services/network-status.service';
import { logger } from '@isa/core/logging';
@Injectable()
export class HttpErrorInterceptor implements HttpInterceptor {
readonly offline$ = injectOnline$().pipe(filter((online) => !online));
readonly injector = inject(Injector);
constructor(private _isaLogProvider: IsaLogProvider) {}
#logger = logger(() => ({
'http-interceptor': 'HttpErrorInterceptor',
}));
#offline$ = injectOnline$().pipe(filter((online) => !online));
#injector = inject(Injector);
#auth = inject(AuthService);
intercept(
req: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
takeUntil(this.offline$),
takeUntil(this.#offline$),
catchError((error: HttpErrorResponse, caught: any) =>
this.handleError(error),
),
@@ -33,18 +34,22 @@ export class HttpErrorInterceptor implements HttpInterceptor {
}
handleError(error: HttpErrorResponse): Observable<any> {
if (error.status === 401) {
const strategy = this.injector.get(LoginStrategy);
return this.#auth.initialized$.pipe(
mergeMap((initialized) => {
if (initialized && error.status === 401) {
const strategy = this.#injector.get(LoginStrategy);
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
mergeMap(() => NEVER),
);
}
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
mergeMap(() => NEVER),
);
}
if (!error.url.endsWith('/isa/logging')) {
this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error);
}
if (!error.url.endsWith('/isa/logging')) {
this.#logger.error('Http Error', error);
}
return throwError(error);
return throwError(() => error);
}),
);
}
}

View File

@@ -1,10 +1,11 @@
import { coerceArray } from '@angular/cdk/coercion';
import { inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { Config } from '@core/config';
import { isNullOrUndefined } from '@utils/common';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { BehaviorSubject } from 'rxjs';
import { logger } from '@isa/core/logging';
/**
* Storage key for the URL to redirect to after login
@@ -15,9 +16,16 @@ const REDIRECT_URL_KEY = 'auth_redirect_url';
providedIn: 'root',
})
export class AuthService {
private readonly _initialized = new BehaviorSubject<boolean>(false);
#logger = logger(() => ({ service: 'AuthService' }));
#initialized = new BehaviorSubject<boolean>(false);
get initialized$() {
return this._initialized.asObservable();
return this.#initialized.asObservable();
}
#authenticated = new BehaviorSubject<boolean>(false);
get authenticated$() {
return this.#authenticated.asObservable();
}
private _authConfig: AuthConfig;
@@ -27,15 +35,19 @@ export class AuthService {
) {
this._oAuthService.events?.subscribe((event) => {
if (event.type === 'token_received') {
console.log(
'SSO Token Expiration:',
new Date(this._oAuthService.getAccessTokenExpiration()),
);
this.#logger.info('SSO token received', () => ({
tokenExpiration: new Date(
this._oAuthService.getAccessTokenExpiration(),
).toISOString(),
}));
// Handle redirect after successful authentication
setTimeout(() => {
const redirectUrl = this._getAndClearRedirectUrl();
if (redirectUrl) {
this.#logger.debug('Redirecting after authentication', () => ({
redirectUrl,
}));
window.location.href = redirectUrl;
}
}, 100);
@@ -44,50 +56,71 @@ export class AuthService {
}
async init() {
if (this._initialized.getValue()) {
if (this.#initialized.getValue()) {
this.#logger.error(
'AuthService initialization attempted twice',
new Error('Already initialized'),
);
throw new Error('AuthService is already initialized');
}
this.#logger.info('Initializing AuthService');
this._authConfig = this._config.get('@core/auth');
this.#logger.debug('Auth config loaded', () => ({
issuer: this._authConfig.issuer,
clientId: this._authConfig.clientId,
scope: this._authConfig.scope,
}));
this._authConfig.redirectUri = window.location.origin;
this._authConfig.silentRefreshRedirectUri =
window.location.origin + '/silent-refresh.html';
this._authConfig.useSilentRefresh = true;
this.#logger.debug('Auth URIs configured', () => ({
redirectUri: this._authConfig.redirectUri,
silentRefreshRedirectUri: this._authConfig.silentRefreshRedirectUri,
}));
this._oAuthService.configure(this._authConfig);
this._oAuthService.tokenValidationHandler = new JwksValidationHandler();
this.#logger.debug('Setting up automatic silent refresh');
this._oAuthService.setupAutomaticSilentRefresh();
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
this.#logger.debug('Loading discovery document and attempting login');
const authenticated =
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
if (!this._oAuthService.getAccessToken()) {
throw new Error('No access token. User is not authenticated.');
}
this.#authenticated.next(authenticated);
this.#logger.info('AuthService initialized', () => ({ authenticated }));
this._initialized.next(true);
this.#initialized.next(true);
}
isAuthenticated() {
return this.isIdTokenValid();
return this.#authenticated.getValue();
}
isIdTokenValid() {
console.log(
'ID Token Expiration:',
new Date(this._oAuthService.getIdTokenExpiration()),
);
return this._oAuthService.hasValidIdToken();
const expiration = new Date(this._oAuthService.getIdTokenExpiration());
const isValid = this._oAuthService.hasValidIdToken();
this.#logger.debug('ID token validation check', () => ({
expiration: expiration.toISOString(),
isValid,
}));
return isValid;
}
isAccessTokenValid() {
console.log(
'ACCESS Token Expiration:',
new Date(this._oAuthService.getAccessTokenExpiration()),
);
return this._oAuthService.hasValidAccessToken();
const expiration = new Date(this._oAuthService.getAccessTokenExpiration());
const isValid = this._oAuthService.hasValidAccessToken();
this.#logger.debug('Access token validation check', () => ({
expiration: expiration.toISOString(),
isValid,
}));
return isValid;
}
getToken() {
@@ -111,6 +144,7 @@ export class AuthService {
if (isNullOrUndefined(token)) {
return null;
}
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
@@ -135,18 +169,22 @@ export class AuthService {
}
login() {
this.#logger.info('Initiating login flow');
this._saveRedirectUrl();
this._oAuthService.initLoginFlow();
}
setKeyCardToken(token: string) {
this.#logger.debug('Setting keycard token');
this._oAuthService.customQueryParams = {
temp_token: token,
};
}
async logout() {
this.#logger.info('Initiating logout');
await this._oAuthService.revokeTokenAndLogout();
this.#logger.info('Logout completed');
}
hasRole(role: string | string[]) {
@@ -163,16 +201,20 @@ export class AuthService {
async refresh() {
try {
this.#logger.debug('Refreshing authentication token');
if (
this._authConfig.responseType.includes('code') &&
this._authConfig.scope.includes('offline_access')
) {
await this._oAuthService.refreshToken();
this.#logger.info('Token refreshed using refresh token');
} else {
await this._oAuthService.silentRefresh();
this.#logger.info('Token refreshed using silent refresh');
}
} catch (error) {
console.error(error);
this.#logger.error('Token refresh failed', error as Error);
}
}
}

View File

@@ -1,4 +1,5 @@
import { Pipe, PipeTransform } from '@angular/core';
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
@Pipe({
name: 'lineType',
@@ -7,8 +8,8 @@ import { Pipe, PipeTransform } from '@angular/core';
})
export class LineTypePipe implements PipeTransform {
transform(value: string, ...args: any[]): 'text' | 'reihe' {
const REIHE_REGEX = /^Reihe:\s*"(.+)\"$/g;
const reihe = REIHE_REGEX.exec(value)?.[1];
const REIHE_REGEX = new RegExp(`^${REIHE_PREFIX_PATTERN}:\\s*"(.+)"$`, 'g');
const reihe = REIHE_REGEX.exec(value)?.[2];
return reihe ? 'reihe' : 'text';
}
}

View File

@@ -1,9 +1,15 @@
import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform } from '@angular/core';
import {
ChangeDetectorRef,
OnDestroy,
Pipe,
PipeTransform,
} from '@angular/core';
import { ApplicationService } from '@core/application';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { isEqual } from 'lodash';
import { Subscription, combineLatest, BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
@Pipe({
name: 'reiheRoute',
@@ -22,10 +28,13 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
private application: ApplicationService,
private cdr: ChangeDetectorRef,
) {
this.subscription = combineLatest([this.application.activatedProcessId$, this.value$])
this.subscription = combineLatest([
this.application.activatedProcessId$,
this.value$,
])
.pipe(distinctUntilChanged(isEqual))
.subscribe(([processId, value]) => {
const REIHE_REGEX = /[";]|Reihe:/g; // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:
const REIHE_REGEX = new RegExp(`[";]|${REIHE_PREFIX_PATTERN}:`, 'g'); // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:/Reihe/Set:/Set/Reihe:
const reihe = value?.replace(REIHE_REGEX, '')?.trim();
if (!reihe) {
@@ -33,9 +42,15 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
return;
}
const main_qs = reihe.split('/')[0];
// Entferne Zahlen am Ende, die mit Leerzeichen, Komma, Slash oder Semikolon getrennt sind
// Beispiele: "Harry Potter 1" -> "Harry Potter", "Harry Potter,1" -> "Harry Potter", "Harry Potter/2" -> "Harry Potter"
const main_qs = reihe
.split('/')[0]
.replace(/[\s,;]+\d+$/g, '')
.trim();
const path = this.navigation.getArticleSearchResultsPath(processId).path;
const path =
this.navigation.getArticleSearchResultsPath(processId).path;
this.result = {
path,

View File

@@ -0,0 +1,5 @@
/**
* Shared regex pattern for matching Reihe line prefixes.
* Matches: "Reihe:", "Reihe/Set:", or "Set/Reihe:"
*/
export const REIHE_PREFIX_PATTERN = '(Reihe|Reihe\\/Set|Set\\/Reihe)';

View File

@@ -36,13 +36,21 @@ import { CrmCustomerService } from '@domain/crm';
import { MessageModalComponent, MessageModalData } from '@modal/message';
import { GenderSettingsService } from '@shared/services/gender';
import { toSignal } from '@angular/core/rxjs-interop';
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
import { CustomerAdapter } from '@isa/checkout/data-access';
import {
CrmTabMetadataService,
Customer,
AssignedPayer,
} from '@isa/crm/data-access';
import {
CustomerAdapter,
ShippingAddressAdapter,
} from '@isa/checkout/data-access';
import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { NavigationStateService } from '@isa/core/navigation';
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
export interface CustomerDetailsViewMainState {
isBusy: boolean;
@@ -407,9 +415,18 @@ export class CustomerDetailsViewMainComponent
await this._updateNotifcationChannelsAsync(currentBuyer);
this._setPayer();
await this._setPayer();
this._setShippingAddress();
await this._setShippingAddress();
// #5461 Priority fix: Check for regular shopping cart items BEFORE reward return URL
// This ensures that if a user has items in their regular cart, that takes precedence
// over any lingering reward flow context
if (this.shoppingCartHasItems) {
await this.#rewardSelectionPopUpFlow(this.processId);
this.setIsBusy(false);
return;
}
// #5262 Check for reward selection flow before navigation
if (this.hasReturnUrl()) {
@@ -429,16 +446,11 @@ export class CustomerDetailsViewMainComponent
return;
}
// Regular checkout navigation
if (this.shoppingCartHasItems) {
await this.#rewardSelectionPopUpFlow(this.processId);
} else {
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
await this._router.navigate(path);
}
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
await this._router.navigate(path);
this.setIsBusy(false);
}
@@ -631,8 +643,46 @@ export class CustomerDetailsViewMainComponent
}
}
@log
_setPayer() {
@logAsync
async _setPayer() {
// Check if there's a selected payer in metadata (from previous address selection)
const selectedPayerId = this.crmTabMetadataService.selectedPayerId(
this.processId,
);
if (selectedPayerId) {
// Load the selected payer from metadata
try {
const payerResponse = await this.customerService
.getPayer(selectedPayerId)
.toPromise();
if (payerResponse?.result) {
// Create AssignedPayer structure expected by adapter
// Type cast needed due to incompatible enum types between CRM and Checkout APIs
const assignedPayer = {
payer: {
id: selectedPayerId,
data: payerResponse.result,
},
} as AssignedPayer;
const payer = CustomerAdapter.toPayerFromAssignedPayer(assignedPayer);
if (payer) {
this._checkoutService.setPayer({
processId: this.processId,
payer,
});
return;
}
}
} catch (error) {
console.error('Failed to load selected payer from metadata', error);
}
}
// Fallback to current payer from component state
if (this.payer) {
this._checkoutService.setPayer({
processId: this.processId,
@@ -641,8 +691,41 @@ export class CustomerDetailsViewMainComponent
}
}
@log
_setShippingAddress() {
@logAsync
async _setShippingAddress() {
// Check if there's a selected shipping address in metadata (from previous address selection)
const selectedShippingAddressId =
this.crmTabMetadataService.selectedShippingAddressId(this.processId);
if (selectedShippingAddressId) {
// Load the selected shipping address from metadata
try {
const addressResponse = await this.customerService
.getShippingAddress(selectedShippingAddressId)
.toPromise();
if (addressResponse?.result) {
const shippingAddress = ShippingAddressAdapter.fromCrmShippingAddress(
addressResponse.result as CrmShippingAddressDTO,
);
if (shippingAddress) {
this._checkoutService.setShippingAddress({
processId: this.processId,
shippingAddress,
});
return;
}
}
} catch (error) {
console.error(
'Failed to load selected shipping address from metadata',
error,
);
}
}
// Fallback to current shipping address from component state
if (this.shippingAddress) {
this._checkoutService.setShippingAddress({
processId: this.processId,

View File

@@ -1,113 +1,151 @@
<div class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4">
<div>
<img class="rounded shadow mx-auto w-[5.9rem]" [src]="orderItem?.product?.ean | productImage" [alt]="orderItem?.product?.name" />
</div>
<div class="grid grid-flow-row gap-2">
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
<a
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
>
Historie
</a>
}
</div>
<div class="font-bold text-lg">
{{ orderItem?.product?.name }}
</div>
<div>
<span class="isa-label">
{{ processingStatus$ | async | orderItemProcessingStatus }}
</span>
</div>
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
<div class="grid grid-flow-row gap-2">
<div class="col-data">
<div class="col-label">Menge</div>
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
</div>
<div class="col-data">
<div class="col-label">Format</div>
<div class="col-value grid-flow-col grid gap-3 items-center justify-start">
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
<span>{{ orderItem?.product?.formatDetail }}</span>
</div>
</div>
<div class="col-data">
<div class="col-label">ISBN/EAN</div>
<div class="col-value">{{ orderItem?.product?.ean }}</div>
</div>
<div class="col-data">
<div class="col-label">Preis</div>
<div class="col-value">{{ orderItem?.unitPrice?.value?.value | currency: orderItem?.unitPrice?.value?.currency : 'code' }}</div>
</div>
<div class="col-data">
<div class="col-label">MwSt</div>
<div class="col-value">{{ orderItem?.unitPrice?.vat?.inPercent }}%</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Lieferant</div>
<div class="col-value">
{{ orderItemSubsetItem?.supplier?.data?.name }}
</div>
</div>
<div class="col-data">
<div class="col-label">Meldenummer</div>
<div class="col-value">{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}</div>
</div>
<div class="col-data">
<div class="col-label">Vsl. Lieferdatum</div>
<div class="col-value">
{{ orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy' }}
</div>
</div>
@if (orderItemSubsetItem?.preferredPickUpDate) {
<div class="col-data">
<div class="col-label">Zurücklegen bis</div>
<div class="col-value">
{{ orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy' }}
</div>
</div>
}
<hr />
@if (orderItemSubsetItem?.compartmentCode) {
<div class="col-data">
<div class="col-label">Abholfachnummer</div>
<div class="col-value">
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
@if (orderItemSubsetItem?.compartmentInfo) {
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
}
</div>
</div>
}
<div class="col-data">
<div class="col-label">Vormerker</div>
<div class="col-value">{{ isPrebooked$ | async }}</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Zahlungsweg</div>
<div class="col-value">-</div>
</div>
<div class="col-data">
<div class="col-label">Zahlungsart</div>
<div class="col-value">
{{ orderPaymentType$ | async | paymentType }}
</div>
</div>
<div class="col-data">
<div class="col-label">Anmerkung</div>
<div class="col-value">
{{ orderItemSubsetItem?.specialComment || '-' }}
</div>
</div>
</div>
}
</div>
</div>
<div
class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4"
>
<div class="flex flex-col gap-2 justify-start items-center">
@let ean = orderItem?.product?.ean;
@let name = orderItem?.product?.name;
@if (ean && name) {
<img
class="rounded shadow mx-auto w-[5.9rem]"
[src]="ean | productImage"
[alt]="name"
/>
}
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="grid grid-flow-row gap-2">
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
<a
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
>
Historie
</a>
}
</div>
<div class="font-bold text-lg">
{{ orderItem?.product?.name }}
</div>
<div>
<span class="isa-label">
{{ processingStatus$ | async | orderItemProcessingStatus }}
</span>
</div>
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
<div class="grid grid-flow-row gap-2">
<div class="col-data">
<div class="col-label">Menge</div>
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
</div>
<div class="col-data">
<div class="col-label">Format</div>
<div
class="col-value grid-flow-col grid gap-3 items-center justify-start"
>
@let format = orderItem?.product?.format;
@if (format) {
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
}
<span>{{ orderItem?.product?.formatDetail }}</span>
</div>
</div>
<div class="col-data">
<div class="col-label">ISBN/EAN</div>
<div class="col-value">{{ orderItem?.product?.ean }}</div>
</div>
<div class="col-data">
@if (hasRewardPoints$ | async) {
<div class="col-label">Prämie</div>
<div class="col-value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="col-label">Preis</div>
<div class="col-value">
{{
orderItem?.unitPrice?.value?.value
| currency: orderItem?.unitPrice?.value?.currency : 'code'
}}
</div>
}
</div>
<div class="col-data">
<div class="col-label">MwSt</div>
<div class="col-value">
{{ orderItem?.unitPrice?.vat?.inPercent }}%
</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Lieferant</div>
<div class="col-value">
{{ orderItemSubsetItem?.supplier?.data?.name }}
</div>
</div>
<div class="col-data">
<div class="col-label">Meldenummer</div>
<div class="col-value">
{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}
</div>
</div>
<div class="col-data">
<div class="col-label">Vsl. Lieferdatum</div>
<div class="col-value">
{{
orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy'
}}
</div>
</div>
@if (orderItemSubsetItem?.preferredPickUpDate) {
<div class="col-data">
<div class="col-label">Zurücklegen bis</div>
<div class="col-value">
{{
orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy'
}}
</div>
</div>
}
<hr />
@if (orderItemSubsetItem?.compartmentCode) {
<div class="col-data">
<div class="col-label">Abholfachnummer</div>
<div class="col-value">
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
@if (orderItemSubsetItem?.compartmentInfo) {
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
}
</div>
</div>
}
<div class="col-data">
<div class="col-label">Vormerker</div>
<div class="col-value">{{ isPrebooked$ | async }}</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Zahlungsweg</div>
<div class="col-value">-</div>
</div>
<div class="col-data">
<div class="col-label">Zahlungsart</div>
<div class="col-value">
{{ orderPaymentType$ | async | paymentType }}
</div>
</div>
<div class="col-data">
<div class="col-label">Anmerkung</div>
<div class="col-value">
{{ orderItemSubsetItem?.specialComment || '-' }}
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -1,88 +1,136 @@
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, ChangeDetectionStrategy, Input, OnDestroy, OnInit, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductImagePipe } from '@cdn/product-image';
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
import { OrderItemDTO } from '@generated/swagger/oms-api';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
@Component({
selector: 'page-customer-order-item-list-item',
templateUrl: 'order-item-list-item.component.html',
styleUrls: ['order-item-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-order-item-list-item' },
imports: [
AsyncPipe,
DatePipe,
ProductImagePipe,
CurrencyPipe,
RouterLink,
PaymentTypePipe,
OrderItemProcessingStatusPipe
],
})
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
private _activatedRoute = inject(ActivatedRoute);
private _store = inject(CustomerSearchStore);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy = new Subject<void>();
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
@Input()
get orderItem() {
return this._orderItemSub.getValue();
}
set orderItem(value: OrderItemDTO) {
this._orderItemSub.next(value);
}
orderId$ = this._activatedRoute.params.pipe(map((params) => Number(params.orderId)));
order$ = this._store.order$;
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
customerId$ = this._activatedRoute.params.pipe(map((params) => Number(params.customerId)));
orderItemOrderType$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.features?.orderType));
orderItemSubsetItem$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.subsetItems?.[0]?.data));
orderDetailsHistoryRoute$ = combineLatest([
this.customerId$,
this._store.processId$,
this.orderId$,
this._orderItemSub,
]).pipe(
map(([customerId, processId, orderId, orderItem]) =>
this._navigation.orderDetailsHistoryRoute({ processId, customerId, orderId, orderItemId: orderItem?.id }),
),
);
isPrebooked$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')));
processingStatus$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => subsetItem?.processingStatus));
ngOnInit() {
this.customerId$.pipe(takeUntil(this._onDestroy)).subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(+orderId);
});
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
this._orderItemSub.complete();
}
}
import {
AsyncPipe,
CurrencyPipe,
DatePipe,
DecimalPipe,
} from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
Input,
OnDestroy,
OnInit,
inject,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductImagePipe } from '@cdn/product-image';
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
import { OrderItemDTO } from '@generated/swagger/oms-api';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { IconComponent } from '@shared/components/icon';
@Component({
selector: 'page-customer-order-item-list-item',
templateUrl: 'order-item-list-item.component.html',
styleUrls: ['order-item-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-order-item-list-item' },
imports: [
AsyncPipe,
DatePipe,
ProductImagePipe,
CurrencyPipe,
RouterLink,
PaymentTypePipe,
OrderItemProcessingStatusPipe,
LabelComponent,
IconComponent,
DecimalPipe,
],
})
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
private _activatedRoute = inject(ActivatedRoute);
private _store = inject(CustomerSearchStore);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy = new Subject<void>();
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
@Input()
get orderItem() {
return this._orderItemSub.getValue();
}
set orderItem(value: OrderItemDTO) {
this._orderItemSub.next(value);
}
orderId$ = this._activatedRoute.params.pipe(
map((params) => Number(params.orderId)),
);
order$ = this._store.order$;
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
customerId$ = this._activatedRoute.params.pipe(
map((params) => Number(params.customerId)),
);
orderItemOrderType$ = this._orderItemSub.pipe(
map((orderItem) => orderItem?.features?.orderType),
);
orderItemSubsetItem$ = this._orderItemSub.pipe(
map((orderItem) => orderItem?.subsetItems?.[0]?.data),
);
orderDetailsHistoryRoute$ = combineLatest([
this.customerId$,
this._store.processId$,
this.orderId$,
this._orderItemSub,
]).pipe(
map(([customerId, processId, orderId, orderItem]) =>
this._navigation.orderDetailsHistoryRoute({
processId,
customerId,
orderId,
orderItemId: orderItem?.id,
}),
),
);
isPrebooked$ = this.orderItemSubsetItem$.pipe(
map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')),
);
processingStatus$ = this.orderItemSubsetItem$.pipe(
map((subsetItem) => subsetItem?.processingStatus),
);
hasRewardPoints$ = this._orderItemSub.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
rewardPoints$ = this._orderItemSub.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
Labeltype = Labeltype;
LabelPriority = LabelPriority;
ngOnInit() {
this.customerId$
.pipe(takeUntil(this._onDestroy))
.subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(+orderId);
});
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
this._orderItemSub.complete();
}
}

View File

@@ -40,6 +40,11 @@
[src]="orderItem.product?.ean | productImage"
[alt]="orderItem.product?.name"
/>
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="page-pickup-shelf-details-item__details">
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
@@ -117,10 +122,15 @@
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined) {
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
<div class="detail">
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
@if (hasRewardPoints$ | async) {
<div class="label">Prämie</div>
<div class="value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
}
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {

View File

@@ -21,6 +21,8 @@ button {
}
.page-pickup-shelf-details-item__thumbnail {
@apply flex flex-col items-center gap-2;
img {
@apply rounded shadow-cta w-[3.625rem] max-h-[5.9375rem];
}

View File

@@ -1,20 +1,22 @@
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
import { AsyncPipe, CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
inject, OnDestroy,
inject,
OnDestroy,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image';
import { DBHOrderItemListItemDTO, OrderDTO, ReceiptDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { UiCommonModule } from '@ui/common';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { UiTooltipModule } from '@ui/tooltip';
import { PickupShelfPaymentTypePipe } from '../pipes/payment-type.pipe';
import { IconModule } from '@shared/components/icon';
@@ -48,6 +50,7 @@ export interface PickUpShelfDetailsItemComponentState {
ReactiveFormsModule,
CurrencyPipe,
DatePipe,
DecimalPipe,
AsyncPipe,
ProductImageModule,
TextFieldModule,
@@ -56,12 +59,13 @@ export interface PickUpShelfDetailsItemComponentState {
UiQuantityDropdownModule,
NotificationTypePipe,
NavigateOnClickDirective,
MatomoModule
MatomoModule,
LabelComponent
],
})
export class PickUpShelfDetailsItemComponent
extends ComponentStore<PickUpShelfDetailsItemComponentState>
implements OnInit, OnDestroy
implements OnDestroy
{
private _store = inject(PickupShelfDetailsStore);
@@ -117,6 +121,22 @@ export class PickUpShelfDetailsItemComponent
hasSmsNotification$ = this.smsNotificationDates$.pipe(map((dates) => dates?.length > 0));
/**
* Observable that indicates whether the order item has reward points (Lesepunkte).
* Returns true if the item has a 'praemie' feature.
*/
hasRewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
/**
* Observable that emits the reward points (Lesepunkte) value for the order item.
* Returns the parsed numeric value from the 'praemie' feature, or undefined if not present.
*/
rewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
canChangeQuantity$ = combineLatest([this.orderItem$, this._store.fetchPartial$]).pipe(
map(([item, partialPickup]) => ([16, 8192].includes(item?.processingStatus) || partialPickup) && item.quantity > 1),
);
@@ -167,12 +187,12 @@ export class PickUpShelfDetailsItemComponent
return this._store.receipts;
}
readonly receipts$ = this._store.receipts$;
set receipts(receipts: ReceiptDTO[]) {
this._store.updateReceipts(receipts);
}
readonly receipts$ = this._store.receipts$;
readonly receiptCount$ = this.receipts$.pipe(map((receipts) => receipts?.length));
specialCommentControl = new UntypedFormControl();
@@ -181,7 +201,11 @@ export class PickUpShelfDetailsItemComponent
private _onDestroy$ = new Subject<void>();
expanded: boolean = false;
expanded = false;
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(private _cdr: ChangeDetectorRef) {
super({
@@ -189,8 +213,6 @@ export class PickUpShelfDetailsItemComponent
});
}
ngOnInit() {}
ngOnDestroy() {
// Remove Prev OrderItem from selected list
this._store.selectOrderItem(this.orderItem, false);

View File

@@ -39,6 +39,7 @@
.page-pickup-shelf-list-item__item-thumbnail {
grid-area: thumbnail;
@apply flex flex-col items-center gap-2;
}
.page-pickup-shelf-list-item__item-image {

View File

@@ -10,7 +10,7 @@
[class.page-pickup-shelf-list-item__item-grid-container-main]="primaryOutletActive"
[class.page-pickup-shelf-list-item__item-grid-container-secondary]="primaryOutletActive && isItemSelectable === undefined"
>
<div class="page-pickup-shelf-list-item__item-thumbnail text-center w-[3.125rem] h-[4.9375rem]">
<div class="page-pickup-shelf-list-item__item-thumbnail text-center">
@if (item?.product?.ean | productImage; as productImage) {
<img
class="page-pickup-shelf-list-item__item-image w-[3.125rem] max-h-[4.9375rem]"
@@ -20,6 +20,11 @@
[alt]="item?.product?.name"
/>
}
@if (hasRewardPoints) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div

View File

@@ -5,6 +5,8 @@ import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image
import { EnvironmentService } from '@core/environment';
import { IconModule } from '@shared/components/icon';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { UiCommonModule } from '@ui/common';
import { PickupShelfProcessingStatusPipe } from '../pipes/processing-status.pipe';
import { FormsModule } from '@angular/forms';
@@ -29,7 +31,8 @@ import { MatomoModule } from 'ngx-matomo-client';
UiCommonModule,
PickupShelfProcessingStatusPipe,
NavigateOnClickDirective,
MatomoModule
MatomoModule,
LabelComponent
],
providers: [PickupShelfProcessingStatusPipe],
})
@@ -77,12 +80,24 @@ export class PickUpShelfListItemComponent {
return { 'background-color': this._processingStatusPipe.transform(this.item?.processingStatus, true) };
}
/**
* Indicates whether the order item has reward points (Lesepunkte).
* Returns true if the item has a 'praemie' feature.
*/
get hasRewardPoints() {
return getOrderItemRewardFeature(this.item) !== undefined;
}
selected$ = this.store.selectedListItems$.pipe(
map((selectedListItems) =>
selectedListItems?.find((item) => item?.orderItemSubsetId === this.item?.orderItemSubsetId),
),
);
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(
private _elRef: ElementRef,
private _environment: EnvironmentService,

View File

@@ -6,7 +6,7 @@ import { NavigationRoute } from './defs/navigation-route';
import {
encodeFormData,
mapCustomerInfoDtoToCustomerCreateFormData,
} from 'apps/isa-app/src/page/customer';
} from '@page/customer';
@Injectable({ providedIn: 'root' })
export class CustomerCreateNavigation {
@@ -58,7 +58,7 @@ export class CustomerCreateNavigation {
},
];
let formData = params?.customerInfo
const formData = params?.customerInfo
? encodeFormData(
mapCustomerInfoDtoToCustomerCreateFormData(params.customerInfo),
)

View File

@@ -12,7 +12,7 @@ variables:
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '2'
value: '3'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'

View File

@@ -1,11 +1,11 @@
# Library Reference Guide
> **Last Updated:** 2025-10-27
> **Last Updated:** 2025-01-10
> **Angular Version:** 20.1.2
> **Nx Version:** 21.3.2
> **Total Libraries:** 62
> **Total Libraries:** 63
All 62 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
All 63 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
@@ -82,7 +82,14 @@ A comprehensive print management library for Angular applications providing prin
---
## Core Libraries (5 libraries)
## Core Libraries (6 libraries)
### `@isa/core/auth`
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application. Provides Role enum, RoleService for programmatic checks, and IfRoleDirective for declarative template rendering with automatic JWT token parsing via OAuthService.
**Location:** `libs/core/auth/`
**Testing:** Vitest (18 passing tests)
**Features:** Signal-based reactivity, type-safe Role enum, zero-configuration OAuth2 integration
### `@isa/core/config`
A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { hasLoyaltyCollectCommand } from './has-loyalty-collect-command.helper';
import { DisplayOrderItemSubset } from '@isa/oms/data-access';
describe('hasLoyaltyCollectCommand', () => {
describe('when items is undefined', () => {
it('should return false', () => {
// Act
const result = hasLoyaltyCollectCommand(undefined);
// Assert
expect(result).toBe(false);
});
});
describe('when items is empty array', () => {
it('should return false', () => {
// Arrange
const items: DisplayOrderItemSubset[] = [];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(false);
});
});
describe('when items have no actions', () => {
it('should return false', () => {
// Arrange
const items: DisplayOrderItemSubset[] = [
{
id: 1,
quantity: 1,
} as DisplayOrderItemSubset,
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(false);
});
});
describe('when items have actions but no LOYALTY_COLLECT_COMMAND', () => {
it('should return false', () => {
// Arrange
const items: any[] = [
{
id: 1,
quantity: 1,
actions: [
{ command: 'SOME_OTHER_COMMAND', label: 'Other Action' },
{ command: 'ANOTHER_COMMAND', label: 'Another Action' },
],
},
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(false);
});
});
describe('when items have LOYALTY_COLLECT_COMMAND action', () => {
it('should return true', () => {
// Arrange
const items: any[] = [
{
id: 1,
quantity: 1,
actions: [
{
command: 'LOYALTY_COLLECT_COMMAND',
label: 'Abschließen',
selected: true,
value: 'Abschließen',
},
],
},
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(true);
});
});
describe('when items have multiple actions including LOYALTY_COLLECT_COMMAND', () => {
it('should return true', () => {
// Arrange
const items: any[] = [
{
id: 1,
quantity: 1,
actions: [
{ command: 'SOME_OTHER_COMMAND', label: 'Other Action' },
{
command: 'LOYALTY_COLLECT_COMMAND',
label: 'Abschließen',
selected: true,
value: 'Abschließen',
},
{ command: 'ANOTHER_COMMAND', label: 'Another Action' },
],
},
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,17 @@
import { DisplayOrderItemSubset } from '@isa/oms/data-access';
/**
* Checks if any of the subset items has a LOYALTY_COLLECT_COMMAND action
* @param items - Array of DisplayOrderItemSubset to check
* @returns true if at least one item has a LOYALTY_COLLECT_COMMAND action
*/
export const hasLoyaltyCollectCommand = (
items?: DisplayOrderItemSubset[],
): boolean => {
const firstItem = items?.find((_) => true);
return (
firstItem?.actions?.some((action) =>
action?.command?.includes('LOYALTY_COLLECT_COMMAND'),
) ?? false
);
};

View File

@@ -1,5 +1,6 @@
export * from './get-order-type-feature.helper';
export * from './has-order-type-feature.helper';
export * from './has-loyalty-collect-command.helper';
export * from './checkout-analysis.helpers';
export * from './checkout-business-logic.helpers';
export * from './checkout-data.helpers';

View File

@@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { TabService } from '@isa/core/tabs';
@@ -11,6 +17,12 @@ import { isaActionChevronRight } from '@isa/icons';
*
* Shows customer name and a chevron button for navigation to the order completion page.
*/
export type OpenTaskCardInput = Pick<
DBHOrderItemListItemDTO,
'orderId' | 'firstName' | 'lastName'
>;
@Component({
selector: 'reward-catalog-open-task-card',
standalone: true,
@@ -20,7 +32,7 @@ import { isaActionChevronRight } from '@isa/icons';
<a
class="bg-isa-white flex items-center justify-between px-[22px] py-[20px] rounded-2xl w-[334px] cursor-pointer no-underline"
data-what="open-task-card"
[attr.data-which]="task().orderItemId"
[attr.data-which]="task().orderId"
[routerLink]="routePath()"
>
<div class="flex flex-col gap-1">
@@ -47,7 +59,7 @@ export class OpenTaskCardComponent {
/**
* The open task data to display
*/
readonly task = input.required<DBHOrderItemListItemDTO>();
readonly task = input.required<OpenTaskCardInput>();
/**
* Computed customer name from first and last name
@@ -62,7 +74,9 @@ export class OpenTaskCardComponent {
/**
* Current tab ID for navigation
*/
readonly #tabId = computed(() => this.#tabService.activatedTab()?.id ?? Date.now());
readonly #tabId = computed(
() => this.#tabService.activatedTab()?.id ?? Date.now(),
);
/**
* Route path to the reward order confirmation page.
@@ -74,6 +88,12 @@ export class OpenTaskCardComponent {
console.warn('Missing orderId in task', this.task());
return [];
}
return ['/', this.#tabId(), 'reward', 'order-confirmation', orderId.toString()];
return [
'/',
this.#tabId(),
'reward',
'order-confirmation',
orderId.toString(),
];
});
}

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { OpenRewardTasksResource } from '@isa/oms/data-access';
import { CarouselComponent } from '@isa/ui/carousel';
import { OpenTaskCardComponent } from './open-task-card.component';
@@ -13,6 +13,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
* - Keyboard navigation (Arrow Left/Right)
* - Automatic visibility based on task availability
* - Shared global resource for consistent data across app
* - Deduplicates tasks to show only one card per orderId
*/
@Component({
selector: 'reward-catalog-open-tasks-carousel',
@@ -22,7 +23,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
@if (openTasksResource.hasOpenTasks()) {
<div class="mb-4" data-what="open-tasks-carousel">
<ui-carousel [gap]="'1rem'" [arrowAutoHide]="true">
@for (task of openTasksResource.tasks(); track task.orderItemId) {
@for (task of uniqueTasks(); track task.orderId) {
<reward-catalog-open-task-card [task]="task" />
}
</ui-carousel>
@@ -36,4 +37,23 @@ export class OpenTasksCarouselComponent {
* Global resource managing open reward tasks data
*/
readonly openTasksResource = inject(OpenRewardTasksResource);
/**
* Deduplicated tasks - shows only one task per orderId.
* When multiple order items exist for the same order, only the first one is displayed.
*
* @returns Array of unique tasks filtered by orderId
*/
readonly uniqueTasks = computed(() => {
const tasks = this.openTasksResource.tasks();
const seenOrderIds = new Set<number>();
return tasks.filter(task => {
if (!task.orderId || seenOrderIds.has(task.orderId)) {
return false;
}
seenOrderIds.add(task.orderId);
return true;
});
});
}

View File

@@ -88,7 +88,7 @@ export class RewardActionComponent {
items,
useRedemptionPoints: true,
preSelectOption: { option: 'in-store' },
disabledPurchaseOptions: ['b2b-delivery'],
disabledPurchaseOptions: ['delivery', 'dig-delivery', 'b2b-delivery'],
hideDisabledPurchaseOptions: true,
});

View File

@@ -1,9 +1,9 @@
@if (displayActionCard()) {
<div
class="w-72 desktop-large:w-[24.5rem] justify-between h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
[class.confirmation-list-item-done]="item().status !== 1"
data-which="action-card"
data-what="action-card"
*ifNotRole="Role.CallCenter"
>
@if (!isComplete()) {
<div

View File

@@ -31,7 +31,9 @@ import {
import {
hasOrderTypeFeature,
buildItemQuantityMap,
hasLoyaltyCollectCommand,
} from '@isa/checkout/data-access';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'checkout-confirmation-list-item-action-card',
@@ -43,6 +45,7 @@ import {
ButtonComponent,
DropdownButtonComponent,
DropdownOptionComponent,
IfRoleDirective,
],
providers: [
provideIcons({ isaActionCheck }),
@@ -52,6 +55,7 @@ import {
],
})
export class ConfirmationListItemActionCardComponent {
protected readonly Role = Role;
LoyaltyCollectType = LoyaltyCollectType;
ProcessingStatusState = ProcessingStatusState;
#orderRewardCollectFacade = inject(OrderRewardCollectFacade);
@@ -89,11 +93,25 @@ export class ConfirmationListItemActionCardComponent {
});
isComplete = computed(() => {
return this.processingStatus() !== undefined;
return (
this.processingStatus() !== undefined &&
this.processingStatus() !== ProcessingStatusState.Ordered
);
});
displayActionCard = computed(() =>
hasOrderTypeFeature(this.item().features, ['Rücklage']),
/**
* #5459 - Determines whether the action card should be displayed for this order item.
*
* The action card is shown when ALL of the following conditions are met:
* - The item MUST have the 'Rücklage' order type feature
* - AND one of the following:
* - The item has a loyalty collect command available (for collecting rewards)
* - OR the item processing is complete (for displaying the completed state)
*/
displayActionCard = computed(
() =>
hasOrderTypeFeature(this.item().features, ['Rücklage']) &&
(hasLoyaltyCollectCommand(this.item().subsetItems) || this.isComplete()),
);
constructor() {

View File

@@ -7,6 +7,8 @@ import {
import {
SelectedCustomerResource,
getCustomerName,
SelectedCustomerShippingAddressResource,
SelectedCustomerPayerAddressResource,
} from '@isa/crm/data-access';
import { isaActionEdit } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
@@ -25,10 +27,19 @@ import { NavigationStateService } from '@isa/core/navigation';
})
export class BillingAndShippingAddressCardComponent {
#navigationState = inject(NavigationStateService);
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
#payerAddressResource = inject(SelectedCustomerPayerAddressResource);
tabId = injectTabId();
#customerResource = inject(SelectedCustomerResource).resource;
isLoading = this.#customerResource.isLoading;
isLoading = computed(() => {
return (
this.#customerResource.isLoading() ||
this.#shippingAddressResource.resource.isLoading() ||
this.#payerAddressResource.resource.isLoading()
);
});
customer = computed(() => {
return this.#customerResource.value();
@@ -49,26 +60,44 @@ export class BillingAndShippingAddressCardComponent {
}
payer = computed(() => {
// Prefer selected payer from metadata over customer as payer
const selectedPayer = this.#payerAddressResource.resource.value();
if (selectedPayer) {
return selectedPayer;
}
// Fallback to customer as payer
return this.customer();
});
payerName = computed(() => {
return getCustomerName(this.payer());
const payer = this.payer();
return getCustomerName(payer);
});
payerAddress = computed(() => {
return this.customer()?.address;
const payer = this.payer();
if (!payer) return undefined;
return payer.address;
});
shippingAddress = computed(() => {
// Prefer selected shipping address from metadata over customer default
const selectedAddress = this.#shippingAddressResource.resource.value();
if (selectedAddress) {
return selectedAddress;
}
// Fallback to customer
return this.customer();
});
shippingName = computed(() => {
return getCustomerName(this.shippingAddress());
const shipping = this.shippingAddress();
return getCustomerName(shipping);
});
shippingAddressAddress = computed(() => {
return this.shippingAddress()?.address;
const shipping = this.shippingAddress();
if (!shipping) return undefined;
return shipping.address;
});
}

View File

@@ -88,7 +88,7 @@ export class RewardShoppingCartItemComponent {
tabId: this.#tabId() as unknown as number,
type: 'update',
useRedemptionPoints: true,
disabledPurchaseOptions: ['b2b-delivery'],
disabledPurchaseOptions: ['delivery', 'dig-delivery', 'b2b-delivery'],
hideDisabledPurchaseOptions: true,
});

View File

@@ -9,6 +9,7 @@ import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
import { isResponseArgs } from '@isa/common/data-access';
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
/**
* Orchestrates checkout completion and order creation.
@@ -37,6 +38,7 @@ export class CheckoutCompletionOrchestratorService {
#shoppingCartFacade = inject(ShoppingCartFacade);
#orderCreationFacade = inject(OrderCreationFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
#errorFeedbackDialog = injectFeedbackErrorDialog();
/**
* Complete checkout with CRM data and create orders.
@@ -114,6 +116,12 @@ export class CheckoutCompletionOrchestratorService {
) {
const responseArgs = error.error;
orders = responseArgs.result;
this.#errorFeedbackDialog({
data: {
errorMessage: responseArgs.message,
},
});
// Wenn Bestellungen erstellt wurden, loggen wir eine Warnung aber fahren fort
if (orders.length > 0) {
this.#logger.warn(

816
libs/core/auth/README.md Normal file
View File

@@ -0,0 +1,816 @@
# @isa/core/auth
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Quick Start](#quick-start)
- [Core Concepts](#core-concepts)
- [API Reference](#api-reference)
- [Role (Enum)](#role-enum)
- [RoleService](#roleservice)
- [IfRoleDirective](#ifroledirective)
- [TokenProvider](#tokenprovider)
- [Usage Examples](#usage-examples)
- [Configuration](#configuration)
- [Testing](#testing)
- [Architecture](#architecture)
- [Dependencies](#dependencies)
## Overview
`@isa/core/auth` provides a lightweight, type-safe system for managing role-based authorization in Angular applications. Built with modern Angular patterns (signals, standalone components), it integrates seamlessly with OAuth2 authentication flows.
### The Problem It Solves
Traditional role-checking often involves:
- ❌ String literals scattered throughout templates and components
- ❌ No compile-time safety for role names
- ❌ Manual token parsing and claim extraction
- ❌ Repetitive conditional rendering logic
This library provides:
- ✅ Type-safe `Role` enum with autocomplete
- ✅ Automatic JWT token parsing via `OAuthService`
- ✅ Declarative role-based rendering with `*ifRole` directive
- ✅ Reactive updates using Angular signals
- ✅ Centralized role management
## Features
- 🔐 **Type-Safe Roles** - Enum-based role definitions prevent typos
- 🎯 **Declarative Templates** - `*ifRole` and `*ifNotRole` structural directives
-**Signal-Based** - Reactive role checking with Angular signals
- 🔄 **Flexible Token Provider** - Injectable abstraction with OAuth2 default
- 📝 **Comprehensive Logging** - Integrated with `@isa/core/logging`
- 🧪 **Fully Tested** - 18 unit tests with Vitest
- 🎨 **Standalone** - No module imports required
## Quick Start
**1. Import the directive and Role enum:**
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [IfRoleDirective],
template: `
<!-- Show content only for Store users -->
<div *ifRole="Role.Store">
<h2>Store Dashboard</h2>
<!-- Store-specific features -->
</div>
<!-- Show content only for CallCenter users -->
<div *ifRole="Role.CallCenter">
<h2>CallCenter Dashboard</h2>
<!-- CallCenter-specific features -->
</div>
<!-- Hide content from CallCenter users -->
<div *ifNotRole="Role.CallCenter">
<button>Complete Order</button>
</div>
`
})
export class DashboardComponent {
protected readonly Role = Role; // Expose to template
}
```
**2. Use RoleService programmatically:**
```typescript
import { Component, inject } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
@Component({
selector: 'app-nav',
template: `...`
})
export class NavComponent {
private readonly roleService = inject(RoleService);
ngOnInit() {
if (this.roleService.hasRole(Role.Store)) {
// Enable store-specific navigation
}
}
}
```
**3. No configuration needed!** The library automatically uses `OAuthService` to parse JWT tokens.
## Core Concepts
### Role Enum
Roles are defined as a const object with TypeScript type safety:
```typescript
export const Role = {
CallCenter: 'CallCenter', // HSC (Hugendubel Service Center)
Store: 'Store', // Store/Branch users
} as const;
export type Role = (typeof Role)[keyof typeof Role];
```
**Benefits:**
- Autocomplete in IDEs
- Compile-time checking prevents invalid roles
- Easy to extend with new roles
### Token Provider Pattern
The library uses an injectable `TokenProvider` abstraction to decouple from specific authentication implementations:
```typescript
export interface TokenProvider {
getClaimByKey(key: string): unknown;
}
```
**Default Implementation:**
- Automatically provided via `InjectionToken` factory
- Uses `OAuthService.getAccessToken()` to fetch JWT
- Parses token using `parseJwt()` utility
- No manual configuration required
### Signal-Based Reactivity
The `IfRoleDirective` uses Angular effects for automatic re-rendering when roles change:
```typescript
constructor() {
effect(() => {
this.render(); // Re-render when ifRole/ifNotRole inputs change
});
}
```
## API Reference
### Role (Enum)
Type-safe role definitions for the application.
```typescript
export const Role = {
CallCenter: 'CallCenter', // HSC users
Store: 'Store', // Store users
} as const;
```
**Usage:**
```typescript
import { Role } from '@isa/core/auth';
if (roleService.hasRole(Role.Store)) {
// Type-safe!
}
```
---
### RoleService
Service for programmatic role checks.
#### Methods
##### `hasRole(role: Role | Role[]): boolean`
Check if the authenticated user has specific role(s).
**Parameters:**
- `role` - Single role or array of roles to check (AND logic for arrays)
**Returns:** `true` if user has all specified roles, `false` otherwise
**Examples:**
```typescript
import { inject } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
export class ExampleComponent {
private readonly roleService = inject(RoleService);
checkAccess() {
// Single role check
if (this.roleService.hasRole(Role.Store)) {
console.log('User is a store employee');
}
// Multiple roles (AND logic)
if (this.roleService.hasRole([Role.Store, Role.CallCenter])) {
console.log('User has BOTH store AND call center access');
}
// Multiple checks
const isStore = this.roleService.hasRole(Role.Store);
const isCallCenter = this.roleService.hasRole(Role.CallCenter);
if (isStore || isCallCenter) {
console.log('User has at least one role (OR logic)');
}
}
}
```
**Logging:**
The service logs all role checks at `debug` level:
```
[RoleService] Role check: Store => true
[RoleService] Role check: Store, CallCenter => false
```
---
### IfRoleDirective
Structural directive for declarative role-based rendering.
**Selector:** `[ifRole]`, `[ifRoleElse]`, `[ifNotRole]`, `[ifNotRoleElse]`
#### Inputs
| Input | Type | Description |
|-------|------|-------------|
| `ifRole` | `Role \| Role[]` | Role(s) required to show template |
| `ifRoleElse` | `TemplateRef` | Alternative template if user lacks role |
| `ifNotRole` | `Role \| Role[]` | Role(s) that should NOT be present |
| `ifNotRoleElse` | `TemplateRef` | Alternative template if user has role |
#### Examples
**Basic Usage:**
```html
<!-- Show for Store users -->
<div *ifRole="Role.Store">
Store-specific content
</div>
<!-- Show for CallCenter users -->
<div *ifRole="Role.CallCenter">
CallCenter-specific content
</div>
```
**With Else Template:**
```html
<div *ifRole="Role.Store; else noAccess">
<button>Complete Order</button>
</div>
<ng-template #noAccess>
<p>You don't have permission to complete orders</p>
</ng-template>
```
**Negation (`ifNotRole`):**
```html
<!-- Hide from CallCenter users -->
<div *ifNotRole="Role.CallCenter">
<button>Release Reward</button>
<button>Mark Not Found</button>
<button>Cancel</button>
</div>
```
**Multiple Roles (AND logic):**
```html
<!-- Only show if user has BOTH roles -->
<div *ifRole="[Role.Store, Role.CallCenter]">
Advanced features requiring both roles
</div>
```
**Component Integration:**
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'app-actions',
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifNotRole="Role.CallCenter">
<button (click)="completeOrder()">Complete</button>
</div>
`
})
export class ActionsComponent {
// Expose Role to template
protected readonly Role = Role;
completeOrder() {
// ...
}
}
```
---
### TokenProvider
Injectable abstraction for JWT token parsing.
```typescript
export interface TokenProvider {
getClaimByKey(key: string): unknown;
}
```
**Default Implementation:**
Automatically provided via `InjectionToken` factory:
```typescript
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
'TOKEN_PROVIDER',
{
providedIn: 'root',
factory: () => {
const oAuthService = inject(OAuthService);
return {
getClaimByKey: (key: string) => {
const claims = parseJwt(oAuthService.getAccessToken());
return claims?.[key] ?? null;
},
};
},
},
);
```
**Custom Provider (Advanced):**
Override the default implementation:
```typescript
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
providers: [
{
provide: TOKEN_PROVIDER,
useValue: {
getClaimByKey: (key: string) => {
// Custom token parsing logic
return myCustomAuthService.getClaim(key);
}
} as TokenProvider
}
]
```
---
### parseJwt()
Utility function to parse JWT tokens.
```typescript
export function parseJwt(
token: string | null
): Record<string, unknown> | null
```
**Parameters:**
- `token` - JWT token string or null
**Returns:** Parsed claims object or null
**Example:**
```typescript
import { parseJwt } from '@isa/core/auth';
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const claims = parseJwt(token);
console.log(claims?.['role']); // ['Store']
console.log(claims?.['sub']); // User ID
```
## Usage Examples
### Example 1: Conditional Navigation
```typescript
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'app-side-menu',
standalone: true,
imports: [RouterLink, IfRoleDirective],
template: `
<nav>
<!-- Store-only navigation -->
<a *ifRole="Role.Store" routerLink="/inventory">
Inventory Management
</a>
<a *ifRole="Role.Store" routerLink="/store-orders">
Store Orders
</a>
<!-- CallCenter-only navigation -->
<a *ifRole="Role.CallCenter" routerLink="/customer-service">
Customer Service
</a>
<!-- Show for both roles -->
<a routerLink="/dashboard">
Dashboard
</a>
</nav>
`
})
export class SideMenuComponent {
protected readonly Role = Role;
}
```
### Example 2: Guard with RoleService
```typescript
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { RoleService, Role } from '@isa/core/auth';
export const storeGuard: CanActivateFn = () => {
const roleService = inject(RoleService);
const router = inject(Router);
if (roleService.hasRole(Role.Store)) {
return true;
}
// Redirect to unauthorized page
return router.createUrlTree(['/unauthorized']);
};
// Route configuration
export const routes = [
{
path: 'inventory',
component: InventoryComponent,
canActivate: [storeGuard]
}
];
```
### Example 3: Computed Signals with Roles
```typescript
import { Component, inject, computed } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-dashboard',
template: `
@if (canManageInventory()) {
<button (click)="openInventory()">Manage Inventory</button>
}
@if (canProcessReturns()) {
<button (click)="openReturns()">Process Returns</button>
}
`
})
export class DashboardComponent {
private readonly roleService = inject(RoleService);
// Computed permissions
canManageInventory = computed(() =>
this.roleService.hasRole(Role.Store)
);
canProcessReturns = computed(() =>
this.roleService.hasRole([Role.Store, Role.CallCenter])
);
openInventory() { /* ... */ }
openReturns() { /* ... */ }
}
```
### Example 4: Real-World Component (Reward Order Confirmation)
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'checkout-confirmation-actions',
standalone: true,
imports: [IfRoleDirective, ButtonComponent],
template: `
<div class="action-card">
<div class="message">
Please complete the order or select an action.
</div>
<!-- Hide actions from CallCenter (HSC) users -->
<div *ifNotRole="Role.CallCenter" class="actions">
<select [(ngModel)]="selectedAction">
<option value="collect">Release Reward</option>
<option value="not-found">Not Found</option>
<option value="cancel">Cancel</option>
</select>
<button uiButton color="primary" (click)="complete()">
Complete
</button>
</div>
</div>
`
})
export class ConfirmationActionsComponent {
protected readonly Role = Role;
selectedAction = 'collect';
complete() {
// Complete order logic
}
}
```
## Configuration
### Default Configuration (Recommended)
No configuration needed! The library automatically uses `OAuthService`:
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
standalone: true,
imports: [IfRoleDirective],
// Works out of the box!
})
export class MyComponent {}
```
### Custom TokenProvider (Advanced)
Override the default token provider:
```typescript
import { ApplicationConfig } from '@angular/core';
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: TOKEN_PROVIDER,
useFactory: () => {
const customAuth = inject(CustomAuthService);
return {
getClaimByKey: (key: string) => customAuth.getClaim(key)
} as TokenProvider;
}
}
]
};
```
### JWT Token Structure
The library expects JWT tokens with a `role` claim:
```json
{
"sub": "user123",
"role": ["Store"],
"exp": 1234567890
}
```
**Supported formats:**
- Single role: `"role": "Store"`
- Multiple roles: `"role": ["Store", "CallCenter"]`
## Testing
### Run Tests
```bash
# Run all tests
npx nx test core-auth
# Run with coverage
npx nx test core-auth --coverage.enabled=true
# Skip cache (fresh run)
npx nx test core-auth --skip-nx-cache
```
### Test Results
```
✓ src/lib/role.service.spec.ts (11 tests)
✓ src/lib/if-role.directive.spec.ts (7 tests)
Test Files 2 passed (2)
Tests 18 passed (18)
```
### Testing in Your App
**Mock RoleService:**
```typescript
import { describe, it, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { RoleService, Role } from '@isa/core/auth';
describe('MyComponent', () => {
let roleService: RoleService;
beforeEach(() => {
roleService = {
hasRole: vi.fn().mockReturnValue(true)
} as any;
TestBed.configureTestingModule({
providers: [
{ provide: RoleService, useValue: roleService }
]
});
});
it('should show store content for store users', () => {
vi.spyOn(roleService, 'hasRole').mockReturnValue(true);
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
expect(roleService.hasRole).toHaveBeenCalledWith(Role.Store);
// Assert UI changes
});
});
```
**Mock TokenProvider:**
```typescript
import { TOKEN_PROVIDER, TokenProvider, Role } from '@isa/core/auth';
const mockTokenProvider: TokenProvider = {
getClaimByKey: vi.fn().mockReturnValue([Role.Store])
};
TestBed.configureTestingModule({
providers: [
{ provide: TOKEN_PROVIDER, useValue: mockTokenProvider }
]
});
```
## Architecture
### Design Patterns
**1. Token Provider Pattern**
- Abstracts JWT parsing behind injectable interface
- Allows custom implementations without changing consumers
- Default factory provides OAuthService integration
**2. Signal-Based Reactivity**
- Uses Angular signals for reactive role checks
- Effect-driven template updates
- Minimal re-renders with fine-grained reactivity
**3. Type-Safe Enum Pattern**
- Const object with `as const` assertion
- Provides autocomplete and compile-time safety
- Prevents typos and invalid role strings
### Architecture Diagram
```
┌─────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Route Guards │ │
│ │ (Templates) │ │ │ │
│ └────────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ *ifRole │ hasRole() │
│ ▼ ▼ │
├───────────────────────────────────────────────────┤
│ @isa/core/auth Library │
│ ┌──────────────────┐ ┌─────────────────┐ │
│ │ IfRoleDirective │ │ RoleService │ │
│ │ (Signals) │──────▶│ (Injectable) │ │
│ └──────────────────┘ └────────┬────────┘ │
│ │ │
│ hasRole(Role[]) │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ TokenProvider │ │
│ │ (InjectionToken) │ │
│ └────────┬───────────┘ │
│ │ │
│ getClaimByKey('role') │
│ │ │
├───────────────────────────────────┼──────────────┤
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ OAuthService │ │
│ │ (angular-oauth2-oidc) │ │
│ └──────────┬───────────────┘ │
│ │ │
│ getAccessToken() │
│ │ │
└──────────────────────────┼────────────────────────┘
┌───────────────┐
│ JWT Token │
│ { role: ... }│
└───────────────┘
```
### Role Claim Handling
The library handles both single and multiple role formats:
```typescript
// Single role (string)
{ "role": "Store" }
// Multiple roles (array)
{ "role": ["Store", "CallCenter"] }
// Internal normalization using coerceArray()
const userRolesArray = coerceArray(userRoles);
```
## Dependencies
### External Dependencies
- **`@angular/core`** - Angular framework
- **`@angular/cdk/coercion`** - Array coercion utility
- **`angular-oauth2-oidc`** - OAuth2/OIDC authentication
- **`@isa/core/logging`** - Logging integration
### Internal Dependencies
No other ISA libraries required beyond `@isa/core/logging`.
### Import Path
```typescript
import {
RoleService,
IfRoleDirective,
Role,
TokenProvider,
TOKEN_PROVIDER,
parseJwt
} from '@isa/core/auth';
```
**Path Alias:** `@isa/core/auth``libs/core/auth/src/index.ts`
## Related Documentation
- [CLAUDE.md](../../../CLAUDE.md) - Project guidelines
- [Testing Guidelines](../../../docs/guidelines/testing.md) - Vitest setup
- [Library Reference](../../../docs/library-reference.md) - All libraries
## Related Libraries
- [`@isa/core/logging`](../logging/README.md) - Structured logging
- [`@isa/core/config`](../config/README.md) - Configuration management
- [`@isa/core/storage`](../storage/README.md) - State persistence
---
**License:** ISC
**Version:** 1.0.0
**Last Updated:** 2025-01-10

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "core-auth",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/auth/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/core/auth"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,10 @@
/**
* Core Auth Library
*
* Provides role-based authorization utilities for the ISA Frontend application.
*/
export { RoleService } from './lib/role.service';
export { IfRoleDirective } from './lib/if-role.directive';
export { TokenProvider, TOKEN_PROVIDER, parseJwt } from './lib/token-provider';
export { Role } from './lib/role';

View File

@@ -0,0 +1,157 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { IfRoleDirective } from './if-role.directive';
import { RoleService } from './role.service';
import { Role } from './role';
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifRole="role" data-test="content">Store Content</div>
`,
})
class TestIfRoleComponent {
role = Role.Store;
}
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifRole="role; else noAccess" data-test="content">Store Content</div>
<ng-template #noAccess>
<div data-test="else">No Access</div>
</ng-template>
`,
})
class TestIfRoleElseComponent {
role = Role.Store;
}
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifNotRole="role" data-test="content">Non-Store Content</div>
`,
})
class TestIfNotRoleComponent {
role = Role.Store;
}
describe('IfRoleDirective', () => {
let roleService: { hasRole: ReturnType<typeof vi.fn> };
beforeEach(() => {
roleService = {
hasRole: vi.fn(),
};
TestBed.configureTestingModule({
providers: [{ provide: RoleService, useValue: roleService }],
});
});
describe('ifRole', () => {
it('should render content when user has role', () => {
roleService.hasRole.mockReturnValue(true);
const fixture = TestBed.createComponent(TestIfRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
expect(content?.textContent).toContain('Store Content');
});
it('should not render content when user does not have role', () => {
roleService.hasRole.mockReturnValue(false);
const fixture = TestBed.createComponent(TestIfRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeFalsy();
});
it('should render else template when user does not have role', () => {
roleService.hasRole.mockReturnValue(false);
const fixture = TestBed.createComponent(TestIfRoleElseComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
const elseContent = fixture.nativeElement.querySelector('[data-test="else"]');
expect(content).toBeFalsy();
expect(elseContent).toBeTruthy();
expect(elseContent?.textContent).toContain('No Access');
});
it('should update when role input changes', () => {
roleService.hasRole.mockReturnValue(true);
const fixture = TestBed.createComponent(TestIfRoleComponent);
fixture.detectChanges();
let content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
// Change role and mock to return false
roleService.hasRole.mockReturnValue(false);
fixture.componentInstance.role = Role.CallCenter;
fixture.detectChanges();
content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeFalsy();
});
});
describe('ifNotRole', () => {
it('should render content when user does NOT have role', () => {
roleService.hasRole.mockReturnValue(false);
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
expect(content?.textContent).toContain('Non-Store Content');
});
it('should not render content when user has role', () => {
roleService.hasRole.mockReturnValue(true);
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeFalsy();
});
});
describe('multiple roles', () => {
it('should handle array of roles', () => {
roleService.hasRole.mockReturnValue(true);
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `<div *ifRole="roles" data-test="content">Content</div>`,
})
class TestMultipleRolesComponent {
roles = [Role.Store, Role.CallCenter];
}
const fixture = TestBed.createComponent(TestMultipleRolesComponent);
fixture.detectChanges();
expect(roleService.hasRole).toHaveBeenCalledWith([Role.Store, Role.CallCenter]);
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,105 @@
import {
Directive,
effect,
inject,
input,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { RoleService } from './role.service';
import { Role } from './role';
/**
* Structural directive for role-based conditional rendering using Angular signals
*
* @example
* ```html
* <!-- Show content if user has role -->
* <div *ifRole="Role.Store">Store content</div>
*
* <!-- Show content if user has multiple roles -->
* <div *ifRole="[Role.Store, Role.CallCenter]">Multiple roles</div>
*
* <!-- Show alternate content if user doesn't have role -->
* <div *ifRole="Role.Store; else noAccess">Store content</div>
* <ng-template #noAccess>No access</ng-template>
*
* <!-- Show content if user does NOT have role -->
* <div *ifNotRole="Role.CallCenter">Non-CallCenter content</div>
* ```
*/
@Directive({
selector: '[ifRole],[ifRoleElse],[ifNotRole],[ifNotRoleElse]',
standalone: true,
})
export class IfRoleDirective {
private readonly _templateRef = inject(TemplateRef<{ $implicit: Role | Role[] }>);
private readonly _viewContainer = inject(ViewContainerRef);
private readonly _roleService = inject(RoleService);
/**
* Role(s) required to show the template
*/
readonly ifRole = input<Role | Role[]>();
/**
* Alternative template to show if user doesn't have ifRole
*/
readonly ifRoleElse = input<TemplateRef<unknown>>();
/**
* Role(s) that should NOT be present to show the template
*/
readonly ifNotRole = input<Role | Role[]>();
/**
* Alternative template to show if user has ifNotRole
*/
readonly ifNotRoleElse = input<TemplateRef<unknown>>();
constructor() {
// Use effect to reactively update the view when inputs change
effect(() => {
this.render();
});
}
private get renderTemplateRef(): boolean {
const role = this.ifRole();
const notRole = this.ifNotRole();
if (role) {
return this._roleService.hasRole(role);
}
if (notRole) {
return !this._roleService.hasRole(notRole);
}
return false;
}
private get elseTemplateRef(): TemplateRef<unknown> | undefined {
return this.ifRoleElse() || this.ifNotRoleElse();
}
private render() {
if (this.renderTemplateRef) {
this._viewContainer.clear();
this._viewContainer.createEmbeddedView(this._templateRef, this.getContext());
return;
}
if (this.elseTemplateRef) {
this._viewContainer.clear();
this._viewContainer.createEmbeddedView(this.elseTemplateRef, this.getContext());
return;
}
this._viewContainer.clear();
}
private getContext(): { $implicit: Role | Role[] | undefined } {
return {
$implicit: this.ifRole() || this.ifNotRole(),
};
}
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoleService } from './role.service';
import { TOKEN_PROVIDER, TokenProvider } from './token-provider';
import { Role } from './role';
describe('RoleService', () => {
let service: RoleService;
let tokenProvider: TokenProvider;
beforeEach(() => {
tokenProvider = {
getClaimByKey: vi.fn(),
};
TestBed.configureTestingModule({
providers: [RoleService, { provide: TOKEN_PROVIDER, useValue: tokenProvider }],
});
service = TestBed.inject(RoleService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('hasRole', () => {
it('should return true when user has single required role', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store, Role.CallCenter]);
expect(service.hasRole(Role.Store)).toBe(true);
});
it('should return false when user does not have required role', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should return true when user has all required roles (array)', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([
Role.Store,
Role.CallCenter,
]);
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(true);
});
it('should return false when user is missing one of required roles', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
});
it('should return false when user has no roles in token', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(null);
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should return false when user has undefined roles', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(undefined);
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should handle errors gracefully', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockImplementation(() => {
throw new Error('Token parsing error');
});
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should handle empty role array', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store]);
expect(service.hasRole([])).toBe(true); // empty array means no requirements
});
it('should handle single role as string (not array)', () => {
// JWT might return a single string instead of array for single role
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
expect(service.hasRole(Role.Store)).toBe(true);
expect(service.hasRole(Role.CallCenter)).toBe(false);
});
it('should handle single role string when checking multiple roles', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
});
});
});

View File

@@ -0,0 +1,71 @@
import { coerceArray } from '@angular/cdk/coercion';
import { inject, Injectable } from '@angular/core';
import { logger } from '@isa/core/logging';
import { TOKEN_PROVIDER } from './token-provider';
import { Role } from './role';
/**
* Service for role-based authorization checks
*
* @example
* ```typescript
* import { Role } from '@isa/core/auth';
*
* const roleService = inject(RoleService);
* if (roleService.hasRole(Role.Store)) {
* // Show store features
* }
* ```
*/
@Injectable({
providedIn: 'root',
})
export class RoleService {
private readonly _log = logger({ service: 'RoleService' });
private readonly _tokenProvider = inject(TOKEN_PROVIDER);
/**
* Check if the authenticated user has specific role(s)
*
* @param role Single role or array of roles to check
* @returns true if user has all specified roles, false otherwise
*
* @example
* ```typescript
* import { Role } from '@isa/core/auth';
*
* // Check single role
* hasRole(Role.Store) // true if user has Store role
*
* // Check multiple roles (AND logic)
* hasRole([Role.Store, Role.CallCenter]) // true only if user has BOTH roles
* ```
*/
hasRole(role: Role | Role[]): boolean {
const roles = coerceArray(role);
try {
const userRoles = this._tokenProvider.getClaimByKey('role');
if (!userRoles) {
this._log.debug('No roles found in token claims');
return false;
}
// Coerce userRoles to array in case it's a single string
const userRolesArray = coerceArray(userRoles);
const hasAllRoles = roles.every((r) => userRolesArray.includes(r));
this._log.debug(`Role check: ${roles.join(', ')} => ${hasAllRoles}`, () => ({
requiredRoles: roles,
userRoles: userRolesArray,
}));
return hasAllRoles;
} catch (error) {
this._log.error('Error checking roles', error as Error, () => ({ requiredRoles: roles }));
return false;
}
}
}

View File

@@ -0,0 +1,13 @@
export const Role = {
/**
* HSC
*/
CallCenter: 'CallCenter',
/**
* Filiale
*/
Store: 'Store',
} as const;
export type Role = (typeof Role)[keyof typeof Role];

View File

@@ -0,0 +1,67 @@
import { inject, InjectionToken } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
/**
* Token provider interface for role checking
* The app can provide a custom implementation that returns user roles from the auth token
*/
export interface TokenProvider {
/**
* Get a claim value from the authentication token
* @param key The claim key (e.g., 'role')
* @returns The claim value or null if not found
*/
getClaimByKey(key: string): unknown;
}
/**
* Parse JWT token to extract claims
*/
export function parseJwt(token: string | null): Record<string, unknown> | null {
if (!token) {
return null;
}
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const encoded = window.atob(base64);
return JSON.parse(encoded);
} catch {
return null;
}
}
/**
* Injection token for TokenProvider with default OAuthService implementation
*
* By default, this uses OAuthService to extract claims from the access token.
* You can override this by providing your own implementation:
*
* @example
* ```typescript
* providers: [
* {
* provide: TOKEN_PROVIDER,
* useValue: {
* getClaimByKey: (key) => customTokenService.getClaim(key)
* }
* }
* ]
* ```
*/
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
'TOKEN_PROVIDER',
{
providedIn: 'root',
factory: () => {
const oAuthService = inject(OAuthService);
return {
getClaimByKey: (key: string) => {
const claims = parseJwt(oAuthService.getAccessToken());
return claims?.[key] ?? null;
},
};
},
},
);

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,33 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/core/auth',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-core-auth.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/core/auth',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

@@ -1,5 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { StorageProvider } from './storage-provider';
import {
ResponseArgsOfUserState,
@@ -15,8 +14,8 @@ import {
throwError,
timer,
} from 'rxjs';
import { USER_SUB } from '../tokens';
import { Debounce, ValidateParam } from '@isa/common/decorators';
import { AuthService } from '@core/auth';
import z from 'zod';
import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
@@ -31,14 +30,12 @@ const DEFAULT_USER_STATE_RESPONSE: ResponseArgsOfUserState = {
@Injectable({ providedIn: 'root' })
export class UserStorageProvider implements StorageProvider {
#logger = logger(() => ({
context: 'UserStorageProvider',
}));
#logger = logger(() => ({ service: 'UserStorageProvider' }));
#userStateService = inject(UserStateService);
#userSub = toObservable(inject(USER_SUB));
#authService = inject(AuthService);
#loadUserState = this.#userSub.pipe(
filter((sub) => sub !== 'anonymous'),
#loadUserState = this.#authService.authenticated$.pipe(
filter((authenticated) => authenticated),
switchMap(() =>
this.#userStateService.UserStateGetUserState().pipe(
catchError((error) => {
@@ -50,10 +47,10 @@ export class UserStorageProvider implements StorageProvider {
retry({
count: 3,
delay: (error, retryCount) => {
this.#logger.warn(
`Retrying to load user state, attempt #${retryCount}`,
error,
);
this.#logger.warn('Retrying user state load', () => ({
attempt: retryCount,
error: error.message,
}));
return timer(1000 * retryCount); // Exponential backoff with timer
},
}),
@@ -74,11 +71,15 @@ export class UserStorageProvider implements StorageProvider {
#state: UserState = {};
async init() {
this.#logger.info('Initializing UserStorageProvider');
await firstValueFrom(this.#loadUserState);
this.#logger.info('UserStorageProvider initialized');
}
async reload(): Promise<void> {
this.#logger.info('Reloading user state');
await firstValueFrom(this.#loadUserState);
this.#logger.info('User state reloaded');
}
#setCurrentState(state: UserState) {
@@ -94,18 +95,24 @@ export class UserStorageProvider implements StorageProvider {
@Debounce({ wait: 1000 })
private postNewState(): void {
this.#logger.debug('Saving user state to server');
const state = JSON.stringify(this.#state);
firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: state,
}),
).catch((error) => {
this.#logger.error('Error saving user state:', error);
});
)
.then(() => {
this.#logger.debug('User state saved successfully');
})
.catch((error) => {
this.#logger.error('Failed to save user state', error);
});
}
@ValidateParam(0, z.string().min(1))
set(key: string, value: Record<string, unknown>): void {
this.#logger.debug('Setting user state key', () => ({ key }));
const current = this.#state;
const content = structuredClone(current);
content[key] = value;
@@ -115,12 +122,13 @@ export class UserStorageProvider implements StorageProvider {
@ValidateParam(0, z.string().min(1))
get(key: string): unknown {
const data = structuredClone(this.#state[key]);
return data;
this.#logger.trace('Getting user state key', () => ({ key }));
return structuredClone(this.#state[key]);
}
@ValidateParam(0, z.string().min(1))
clear(key: string): void {
this.#logger.debug('Clearing user state key', () => ({ key }));
const current = this.#state;
if (key in current) {
const content = structuredClone(current);

View File

@@ -1,10 +1,20 @@
import { InjectionToken, signal, Signal } from '@angular/core';
import { inject, InjectionToken, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AuthService } from '@core/auth';
import { map } from 'rxjs';
export const USER_SUB = new InjectionToken<Signal<string>>(
'core.storage.user-sub',
{
factory: () => {
return signal('anonymous');
const auth = inject(AuthService);
return toSignal(
auth.authenticated$.pipe(
// Map to user sub or 'anonymous' if not authenticated
// This ensures that the signal updates when authentication state changes
map(() => auth.getClaimByKey('sub') ?? 'anonymous'),
),
);
},
},
);

View File

@@ -0,0 +1,54 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, PayerService } from '../services';
import { TabService } from '@isa/core/tabs';
import { CrmPayer } from '../schemas';
@Injectable()
export class CustomerPayerAddressResource {
#payerService = inject(PayerService);
#params = signal<{
payerId: number | undefined;
}>({
payerId: undefined,
});
params(params: { payerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<CrmPayer | undefined> => {
if (!params.payerId) {
return undefined;
}
const res = await this.#payerService.fetchPayer(
{
payerId: params.payerId,
},
abortSignal,
);
return res.result as CrmPayer;
},
});
}
@Injectable({ providedIn: 'root' })
export class SelectedCustomerPayerAddressResource extends CustomerPayerAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const payerId = tabId
? this.#customerMetadata.selectedPayerId(tabId)
: undefined;
this.params({ payerId });
});
}
}

View File

@@ -1,5 +1,7 @@
export * from './country.resource';
export * from './customer-payer-address.resource';
export * from './primary-customer-card.resource';
export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';
export * from './payer.resource';

View File

@@ -0,0 +1,52 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
import { CrmCustomerService } from '@domain/crm';
import { PayerDTO } from '@generated/swagger/crm-api';
@Injectable()
export class PayerResource {
#customerService = inject(CrmCustomerService);
#params = signal<{
payerId: number | undefined;
}>({
payerId: undefined,
});
params(params: { payerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<PayerDTO | undefined> => {
if (!params.payerId) {
return undefined;
}
const res = await this.#customerService
.getPayer(params.payerId)
.toPromise();
return res?.result;
},
});
}
@Injectable({ providedIn: 'root' })
export class SelectedPayerResource extends PayerResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const payerId = tabId
? this.#customerMetadata.selectedPayerId(tabId)
: undefined;
this.params({ payerId });
});
}
}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const FetchPayerSchema = z.object({
payerId: z.number().int().describe('Payer identifier'),
});
export type FetchPayer = z.infer<typeof FetchPayerSchema>;
export type FetchPayerInput = z.input<typeof FetchPayerSchema>;

View File

@@ -8,6 +8,7 @@ export * from './customer-feature-groups.schema';
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-payer.schema';
export * from './fetch-shipping-address.schema';
export * from './linked-record.schema';
export * from './notification-channel.schema';

View File

@@ -16,23 +16,43 @@ export const PayerSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
agentComment: z.string().describe('Agent comment').optional(),
communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(),
communicationDetails: CommunicationDetailsSchema.describe(
'Communication details',
).optional(),
deactivationComment: z.string().describe('Deactivation comment').optional(),
defaultPaymentPeriod: z.number().describe('Default payment period').optional(),
defaultPaymentPeriod: z
.number()
.describe('Default payment period')
.optional(),
firstName: z.string().describe('First name').optional(),
gender: GenderSchema.describe('Gender').optional(),
isGuestAccount: z.boolean().describe('Whether guestAccount').optional(),
label: EntityContainerSchema(LabelSchema).describe('Label').optional(),
lastName: z.string().describe('Last name').optional(),
organisation: OrganisationSchema.describe('Organisation information').optional(),
organisation: OrganisationSchema.describe(
'Organisation information',
).optional(),
payerGroup: z.string().describe('Payer group').optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payerStatus: PayerStatusSchema.describe('Current status of the payer account').optional(),
payerStatus: PayerStatusSchema.describe(
'Current status of the payer account',
).optional(),
payerType: z.nativeEnum(PayerType).describe('Payer type').optional(),
paymentTypes: z.array(PaymentSettingsSchema).describe('Payment types').optional(),
standardInvoiceText: z.string().describe('Standard invoice text').optional(),
statusChangeComment: z.string().describe('Status change comment').optional(),
paymentTypes: z
.array(PaymentSettingsSchema)
.describe('Payment types')
.optional(),
standardInvoiceText: z
.string()
.describe('Standard invoice text')
.optional(),
statusChangeComment: z
.string()
.describe('Status change comment')
.optional(),
statusComment: z.string().describe('Status comment').optional(),
title: z.string().describe('Title').optional(),
})
.extend(EntitySchema.shape);
export type CrmPayer = z.infer<typeof PayerSchema>;

View File

@@ -1,4 +1,5 @@
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';
export * from './payer.service';
export * from './shipping-address.service';

View File

@@ -0,0 +1,43 @@
import { inject, Injectable } from '@angular/core';
import { PayerService as GeneratedPayerService } from '@generated/swagger/crm-api';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { FetchPayerInput, FetchPayerSchema, CrmPayer } from '../schemas';
@Injectable({ providedIn: 'root' })
export class PayerService {
#payerService = inject(GeneratedPayerService);
#logger = logger(() => ({
service: 'PayerService',
}));
async fetchPayer(
params: FetchPayerInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<CrmPayer>> {
this.#logger.info('Fetching payer from API');
const { payerId } = FetchPayerSchema.parse(params);
let req$ = this.#payerService
.PayerGetPayer(payerId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched payer');
return res as ResponseArgs<CrmPayer>;
} catch (error) {
this.#logger.error('Error fetching payer', error);
return undefined as unknown as ResponseArgs<CrmPayer>;
}
}
}

View File

@@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { LogisticianDTO } from '@generated/swagger/oms-api';
import { Order } from '../models';
import { DisplayOrder } from '../models';
import { OrderCreationService } from '../services';
/**
@@ -22,7 +22,7 @@ export class OrderCreationFacade {
* @returns Promise resolving to array of created orders
* @throws {Error} If checkoutId is invalid or order creation fails
*/
async createOrdersFromCheckout(checkoutId: number): Promise<Order[]> {
async createOrdersFromCheckout(checkoutId: number): Promise<DisplayOrder[]> {
return this.#orderCreationService.createOrdersFromCheckout(checkoutId);
}

View File

@@ -3,13 +3,13 @@ import { getMainActions } from './get-main-actions.helper';
import { DBHOrderItemListItem } from '../schemas';
describe('getMainActions', () => {
it('should return actions from first item', () => {
it('should return actions from first item when enabled is true', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1' },
{ key: 'action2', value: 'Action 2' },
{ key: 'action1', value: 'Action 1', enabled: true },
{ key: 'action2', value: 'Action 2', enabled: true },
],
} as DBHOrderItemListItem,
];
@@ -19,12 +19,12 @@ describe('getMainActions', () => {
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1' },
{ key: 'action2', value: 'Action 2' },
{ key: 'action1', value: 'Action 1', enabled: true },
{ key: 'action2', value: 'Action 2', enabled: true },
]);
});
it('should filter out actions with enabled boolean property', () => {
it('should only include actions where enabled is true', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
@@ -40,7 +40,9 @@ describe('getMainActions', () => {
const result = getMainActions(items);
// Assert
expect(result).toEqual([{ key: 'action1', value: 'Action 1' }]);
expect(result).toEqual([
{ key: 'action2', value: 'Action 2', enabled: true },
]);
});
it('should filter out actions containing FETCHED_PARTIAL when isPartial is true', () => {
@@ -48,13 +50,24 @@ describe('getMainActions', () => {
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action1',
value: 'Action 1',
command: 'DO_SOMETHING',
enabled: true,
},
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL_ACTION',
enabled: true,
},
{
key: 'action3',
value: 'Action 3',
command: 'DO_ANOTHER_THING',
enabled: true,
},
{ key: 'action3', value: 'Action 3', command: 'DO_ANOTHER_THING' },
],
} as DBHOrderItemListItem,
];
@@ -64,8 +77,18 @@ describe('getMainActions', () => {
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{ key: 'action3', value: 'Action 3', command: 'DO_ANOTHER_THING' },
{
key: 'action1',
value: 'Action 1',
command: 'DO_SOMETHING',
enabled: true,
},
{
key: 'action3',
value: 'Action 3',
command: 'DO_ANOTHER_THING',
enabled: true,
},
]);
});
@@ -74,11 +97,17 @@ describe('getMainActions', () => {
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action1',
value: 'Action 1',
command: 'DO_SOMETHING',
enabled: true,
},
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL_ACTION',
enabled: true,
},
],
} as DBHOrderItemListItem,
@@ -89,11 +118,17 @@ describe('getMainActions', () => {
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action1',
value: 'Action 1',
command: 'DO_SOMETHING',
enabled: true,
},
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL_ACTION',
enabled: true,
},
]);
});
@@ -149,7 +184,12 @@ describe('getMainActions', () => {
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action1',
value: 'Action 1',
command: 'DO_SOMETHING',
enabled: true,
},
{
key: 'action2',
value: 'Action 2',
@@ -160,8 +200,10 @@ describe('getMainActions', () => {
key: 'action3',
value: 'Action 3',
command: 'FETCHED_PARTIAL_ACTION',
enabled: true,
},
{ key: 'action4', value: 'Action 4', enabled: false },
{ key: 'action5', value: 'Action 5', command: 'DO_ANOTHER_THING' },
],
} as DBHOrderItemListItem,
];
@@ -171,7 +213,12 @@ describe('getMainActions', () => {
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action1',
value: 'Action 1',
command: 'DO_SOMETHING',
enabled: true,
},
]);
});
});

View File

@@ -8,7 +8,7 @@ export const getMainActions = (
const firstItem = items?.find((_) => true);
return (
firstItem?.actions
?.filter((action) => typeof action?.enabled !== 'boolean')
?.filter((action) => action?.enabled === true)
?.filter((action) =>
isPartial ? !action?.command?.includes('FETCHED_PARTIAL') : true,
) ?? []

View File

@@ -1,3 +1,4 @@
export * from './return-process';
export * from './reward';
export * from './get-main-actions.helper';
export * from './order';

View File

@@ -0,0 +1,26 @@
/**
* Extracts and parses the reward points (Lesepunkte) value from an order item's features.
*
* @param orderItem - An object containing a features property with key-value pairs
* @returns The parsed numeric value of reward points, or undefined if not present
*
* @example
* ```ts
* const orderItem = { features: { praemie: '12.345' } };
* const points = getOrderItemRewardFeature(orderItem); // returns 12345
* ```
*/
export function getOrderItemRewardFeatureHelper(
orderItem: { features?: { [key: string]: string } } | undefined,
): undefined | number {
if (!orderItem || !orderItem.features) {
return undefined;
}
// Return 12.345 as string needs to be converted to number. Remove . to avoid issues with different locales.
const rewardFeature = orderItem.features['praemie'];
return rewardFeature
? Number(rewardFeature.replace('.', '').replace(',', '.'))
: undefined;
}

View File

@@ -0,0 +1 @@
export { getOrderItemRewardFeatureHelper as getOrderItemRewardFeature } from './get-order-item-reward-feature.helper';

View File

@@ -3,6 +3,22 @@ import { ProcessingStatusState } from '../../models';
import { getProcessingStatusState } from './get-processing-status-state.helper';
describe('getProcessingStatusState', () => {
describe('Ordered status', () => {
it('should return Ordered when all items are Bestellt', () => {
// Arrange
const statuses = [
OrderItemProcessingStatusValue.Bestellt,
OrderItemProcessingStatusValue.Bestellt,
];
// Act
const result = getProcessingStatusState(statuses);
// Assert
expect(result).toBe(ProcessingStatusState.Ordered);
});
});
describe('Cancelled status', () => {
it('should return Cancelled when all items are cancelled', () => {
// Arrange
@@ -117,5 +133,19 @@ describe('getProcessingStatusState', () => {
// Assert
expect(result).toBeUndefined();
});
it('should return undefined when items have mix of ordered and cancelled', () => {
// Arrange
const statuses = [
OrderItemProcessingStatusValue.Bestellt,
OrderItemProcessingStatusValue.Storniert, // 1024
];
// Act
const result = getProcessingStatusState(statuses);
// Assert
expect(result).toBeUndefined();
});
});
});

View File

@@ -24,6 +24,14 @@ export const getProcessingStatusState = (
return undefined;
}
// Check if all statuses are ordered
const allOrdered = statuses.every(
(status) => status === OrderItemProcessingStatusValue.Bestellt,
);
if (allOrdered) {
return ProcessingStatusState.Ordered;
}
// Check if all statuses are cancelled
const allCancelled = statuses.every(
(status) =>

View File

@@ -1,15 +1,15 @@
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
/**
* Order model representing a completed checkout order.
* This is an alias to the OMS API's DisplayOrderDTO.
*
* @remarks
* DisplayOrderDTO contains:
* - Order metadata (orderNumber, orderDate, orderType, orderValue)
* - Customer information (buyer, payer, shippingAddress)
* - Order items and their details
* - Payment information
* - Branch and delivery information
*/
export type Order = DisplayOrderDTO;
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
/**
* Order model representing a completed checkout order.
* This is an alias to the OMS API's DisplayOrderDTO.
*
* @remarks
* DisplayOrderDTO contains:
* - Order metadata (orderNumber, orderDate, orderType, orderValue)
* - Customer information (buyer, payer, shippingAddress)
* - Order items and their details
* - Payment information
* - Branch and delivery information
*/
export type DisplayOrder = DisplayOrderDTO;

View File

@@ -1,9 +1,9 @@
export * from './address-type';
export * from './buyer';
export * from './can-return';
export * from './display-order';
export * from './eligible-for-return';
export * from './logistician';
export * from './order';
export * from './processing-status-state';
export * from './quantity';
export * from './receipt-item-list-item';

View File

@@ -2,6 +2,8 @@
* Processing status state types for order items
*/
export const ProcessingStatusState = {
/** Item is still ordered and pending processing */
Ordered: 'ordered',
/** Item was cancelled by customer, merchant, or supplier */
Cancelled: 'cancelled',
/** Item was not found / not available */

View File

@@ -1,10 +1,18 @@
import { EntitySchema, DateRangeSchema } from '@isa/common/data-access';
import {
EntitySchema,
DateRangeSchema,
KeyValueOfStringAndStringSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { OrderItemProcessingStatusValueSchema } from './order-item-processing-status-value.schema';
// Forward declaration for circular reference
export const DisplayOrderItemSubsetSchema = z
.object({
actions: z
.array(KeyValueOfStringAndStringSchema)
.describe('Possible actions')
.optional(),
compartmentCode: z.string().describe('Compartment code').optional(),
compartmentInfo: z.string().describe('Compartment information').optional(),
compartmentStart: z.string().describe('Compartment start').optional(),

View File

@@ -69,5 +69,3 @@ export const DisplayOrderSchema = z
TermsOfDeliverySchema.describe('Terms of delivery').optional(),
})
.extend(EntitySchema.shape);
export type DisplayOrder = z.infer<typeof DisplayOrderSchema>;

View File

@@ -12,7 +12,7 @@ import { firstValueFrom } from 'rxjs';
* Service for fetching open reward distribution tasks (Prämienausgabe) from the OMS API.
*
* Provides cached access to unfinished reward orders with automatic request deduplication.
* These are reward orders in processing statuses 16 (InPreparation) and 128 (ReadyForPickup)
* These are reward orders in processing statuses 16 (Bestellt) and 8192 (Nachbestellt)
* that need to be completed.
*/
@Injectable({ providedIn: 'root' })
@@ -24,7 +24,7 @@ export class OpenRewardTasksService {
* Fetches open reward distribution tasks (unfinished Prämienausgabe orders).
*
* Returns reward orders that are:
* - In status 16 (InPreparation) or 128 (ReadyForPickup)
* - In status 16 (Bestellt) or 8192 (Nachbestellt)
* - Flagged as reward items (praemie: "1-")
*
* Results are cached for 1 minute to balance freshness with performance.
@@ -42,7 +42,7 @@ export class OpenRewardTasksService {
const payload: QueryTokenDTO = {
input: {},
filter: {
orderitemprocessingstatus: '16', // InPreparation(16) and ReadyForPickup(128)
orderitemprocessingstatus: '16;8192', // Bestellt(16) and Nachbestellt(8192)
praemie: '1-', // Reward items only
supplier_id: '16',
},

View File

@@ -1,87 +1,87 @@
import { inject, Injectable } from '@angular/core';
import {
OrderCheckoutService,
LogisticianService,
LogisticianDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Order } from '../models';
/**
* Service for creating orders from checkout.
*
* @remarks
* This service handles order creation operations that are part of the OMS domain.
* It provides methods to:
* - Create orders from a completed checkout
* - Retrieve logistician information
*/
@Injectable({ providedIn: 'root' })
export class OrderCreationService {
#logger = logger(() => ({ service: 'OrderCreationService' }));
readonly #orderCheckoutService = inject(OrderCheckoutService);
readonly #logisticianService = inject(LogisticianService);
/**
* Creates orders from a checkout.
*
* @param checkoutId - The ID of the checkout to create orders from
* @returns Promise resolving to array of created orders
* @throws {Error} If checkoutId is invalid or order creation fails
*/
async createOrdersFromCheckout(checkoutId: number): Promise<Order[]> {
if (!checkoutId) {
throw new Error(`Invalid checkoutId: ${checkoutId}`);
}
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
checkoutId,
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to create orders', error);
throw error;
}
return res.result as Order[];
}
/**
* Retrieves logistician information.
*
* @param logisticianNumber - The logistician number to retrieve (defaults to '2470')
* @param abortSignal - Optional signal to abort the operation
* @returns Promise resolving to logistician data
* @throws {Error} If logistician is not found or request fails
*/
async getLogistician(
logisticianNumber = '2470',
abortSignal?: AbortSignal,
): Promise<LogisticianDTO> {
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to get logistician', error);
throw error;
}
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
}
}
import { inject, Injectable } from '@angular/core';
import {
OrderCheckoutService,
LogisticianService,
LogisticianDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { DisplayOrder } from '../models';
/**
* Service for creating orders from checkout.
*
* @remarks
* This service handles order creation operations that are part of the OMS domain.
* It provides methods to:
* - Create orders from a completed checkout
* - Retrieve logistician information
*/
@Injectable({ providedIn: 'root' })
export class OrderCreationService {
#logger = logger(() => ({ service: 'OrderCreationService' }));
readonly #orderCheckoutService = inject(OrderCheckoutService);
readonly #logisticianService = inject(LogisticianService);
/**
* Creates orders from a checkout.
*
* @param checkoutId - The ID of the checkout to create orders from
* @returns Promise resolving to array of created orders
* @throws {Error} If checkoutId is invalid or order creation fails
*/
async createOrdersFromCheckout(checkoutId: number): Promise<DisplayOrder[]> {
if (!checkoutId) {
throw new Error(`Invalid checkoutId: ${checkoutId}`);
}
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
checkoutId,
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to create orders', error);
throw error;
}
return res.result as DisplayOrder[];
}
/**
* Retrieves logistician information.
*
* @param logisticianNumber - The logistician number to retrieve (defaults to '2470')
* @param abortSignal - Optional signal to abort the operation
* @returns Promise resolving to logistician data
* @throws {Error} If logistician is not found or request fails
*/
async getLogistician(
logisticianNumber = '2470',
abortSignal?: AbortSignal,
): Promise<LogisticianDTO> {
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to get logistician', error);
throw error;
}
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
}
}

View File

@@ -9,9 +9,9 @@
></ng-icon>
</span>
<p
class="isa-text-body-1-bold text-isa-neutral-900"
class="isa-text-body-1-bold text-isa-neutral-900 whitespace-pre-line text-center"
data-what="error-message"
>
{{ data.errorMessage }}
{{ data.errorMessage ?? 'Unbekannter Fehler' }}
</p>
</div>

View File

@@ -8,7 +8,7 @@ import { isaActionClose } from '@isa/icons';
*/
export interface FeedbackErrorDialogData {
/** The Error message text to display in the dialog */
errorMessage: string;
errorMessage?: string;
}
/**

76779
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,7 @@
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
"@isa/common/print": ["libs/common/print/src/index.ts"],
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
"@isa/core/navigation": ["libs/core/navigation/src/index.ts"],