diff --git a/.vscode/settings.json b/.vscode/settings.json index db08bce69..a11e0188e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -91,5 +91,5 @@ { "file": "docs/guidelines/testing.md" } - ] + ], } diff --git a/libs/core/storage/src/lib/hash.utils.ts b/libs/core/storage/src/lib/hash.utils.ts index 674ea86f2..22e44119a 100644 --- a/libs/core/storage/src/lib/hash.utils.ts +++ b/libs/core/storage/src/lib/hash.utils.ts @@ -1,4 +1,13 @@ -export function hash(obj: object): string { - // TODO: Implement hash function - return JSON.stringify(obj); +export function hash(obj: object | string): string { + if (typeof obj === 'string') { + return obj; + } + + const str = JSON.stringify(obj, Object.keys(obj).sort()); + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return hash.toString(16); } diff --git a/libs/core/storage/src/lib/signal-store-feature.ts b/libs/core/storage/src/lib/signal-store-feature.ts index b29729c6d..7c89cd873 100644 --- a/libs/core/storage/src/lib/signal-store-feature.ts +++ b/libs/core/storage/src/lib/signal-store-feature.ts @@ -1,15 +1,19 @@ import { Type } from '@angular/core'; import { getState, patchState, signalStoreFeature, withHooks, withMethods } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { StorageProvider } from './storage-provider'; import { injectStorage } from './storage'; +import { debounceTime, pipe, switchMap } from 'rxjs'; export function withStorage(storageKey: string, storageProvider: Type) { return signalStoreFeature( withMethods((store, storage = injectStorage(storageProvider)) => ({ - storeState: () => { - const state = getState(store); - storage.set(storageKey, state); - }, + storeState: rxMethod( + pipe( + debounceTime(1000), + switchMap(() => storage.set(storageKey, getState(store))), + ), + ), restoreState: async () => { const data = await storage.get(storageKey); if (data) { diff --git a/libs/core/storage/src/lib/storage.ts b/libs/core/storage/src/lib/storage.ts index 66df3fbe5..d3c227f23 100644 --- a/libs/core/storage/src/lib/storage.ts +++ b/libs/core/storage/src/lib/storage.ts @@ -1,49 +1,40 @@ -import { inject, Type } from '@angular/core'; +import { inject, InjectionToken, Type } from '@angular/core'; import { StorageProvider } from './storage-provider'; import { z } from 'zod'; +import { OAuthService } from 'angular-oauth2-oidc'; import { hash } from './hash.utils'; +export const USER_SUB = new InjectionToken<() => string>('core.storage.user-sub', { + factory: () => { + const auth = inject(OAuthService, { optional: true }); + return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous'; + }, +}); + export class Storage { + private readonly userSub = inject(USER_SUB); + constructor(private storageProvider: StorageProvider) {} + private getKey(token: string | object): string { + const userSub = this.userSub(); + return `${userSub}:${hash(token)}`; + } + set(token: string | object, value: T): Promise { - let key: string; - - if (typeof token === 'string') { - key = token; - } else { - key = hash(token); - } - - return this.storageProvider.set(key, value); + return this.storageProvider.set(this.getKey(token), value); } async get(token: string | object, schema?: z.ZodType): Promise { - let key: string; - - if (typeof token === 'string') { - key = token; - } else { - key = hash(token); - } - - const data = await this.storageProvider.get(key); + const data = await this.storageProvider.get(this.getKey(token)); if (schema) { - return await schema.parse(data); + return schema.parse(data); } return data; } async clear(token: string | object): Promise { - let key: string; - - if (typeof token === 'string') { - key = token; - } else { - key = hash(token); - } - - return this.storageProvider.clear(key); + return this.storageProvider.clear(this.getKey(token)); } } diff --git a/libs/oms/data-access/src/lib/errors/index.ts b/libs/oms/data-access/src/lib/errors/index.ts new file mode 100644 index 000000000..4797ae6aa --- /dev/null +++ b/libs/oms/data-access/src/lib/errors/index.ts @@ -0,0 +1,2 @@ +export * from './oms.error'; +export * from './return-process.error'; diff --git a/libs/oms/data-access/src/lib/errors/oms.error.ts b/libs/oms/data-access/src/lib/errors/oms.error.ts new file mode 100644 index 000000000..8d3d3f7aa --- /dev/null +++ b/libs/oms/data-access/src/lib/errors/oms.error.ts @@ -0,0 +1,5 @@ +export abstract class OmsError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/libs/oms/data-access/src/lib/errors/return-process.error.ts b/libs/oms/data-access/src/lib/errors/return-process.error.ts new file mode 100644 index 000000000..49c366130 --- /dev/null +++ b/libs/oms/data-access/src/lib/errors/return-process.error.ts @@ -0,0 +1,36 @@ +import { OmsError } from './oms.error'; + +export const ReturnProcessErrorCode = { + NO_RETURNABLE_ITEMS: 'NO_RETURNABLE_ITEMS', + MISMATCH_RETURNABLE_ITEMS: 'MISMATCH_RETURNABLE_ITEMS', +} as const; + +export type ReturnProcessErrorCode = + (typeof ReturnProcessErrorCode)[keyof typeof ReturnProcessErrorCode]; + +export class ReturnProcessError extends OmsError { + constructor( + public readonly code: ReturnProcessErrorCode, + message?: string, + ) { + super(message || code); + } +} + +export class ReturnProcessNoReturnableItemsError extends ReturnProcessError { + constructor(params: { processId: number; receiptId: number }) { + super( + ReturnProcessErrorCode.NO_RETURNABLE_ITEMS, + `No returnable items found for process id ${params.processId} and receipt id ${params.receiptId}.`, + ); + } +} + +export class ReturnProcessMismatchReturnableItemsError extends ReturnProcessError { + constructor(params: { processId: number; items: number; returnableItems: number }) { + super( + ReturnProcessErrorCode.MISMATCH_RETURNABLE_ITEMS, + `Mismatch in returnable items for process id ${params.processId}. Expected ${params.items} returnable items, but found ${params.returnableItems}.`, + ); + } +} diff --git a/libs/oms/data-access/src/lib/return-process.store.spec.ts b/libs/oms/data-access/src/lib/return-process.store.spec.ts new file mode 100644 index 000000000..5b68a212e --- /dev/null +++ b/libs/oms/data-access/src/lib/return-process.store.spec.ts @@ -0,0 +1,148 @@ +import { createServiceFactory } from '@ngneat/spectator/jest'; +import { ReturnProcessStore } from './return-process.store'; +import { IDBStorageProvider } from '@isa/core/storage'; +import { ProcessService } from '@isa/core/process'; +import { patchState } from '@ngrx/signals'; +import { setAllEntities } from '@ngrx/signals/entities'; +import { + ReturnProcessMismatchReturnableItemsError, + ReturnProcessNoReturnableItemsError, +} from './errors'; +import { ReturnProcess } from './models'; + +const TEST_ITEMS: Record = { + 1: { + id: 1, + actions: [{ key: 'canReturn', value: 'true' }], + product: { ean: '1234567890', format: 'TB', formatDetails: 'Taschenbuch' }, + quantity: { quantity: 1 }, + }, + 2: { + id: 2, + actions: [{ key: 'canReturn', value: 'false' }], + product: { ean: '0987654321', format: 'GEB', formatDetails: 'Buch' }, + quantity: { quantity: 1 }, + }, + 3: { + id: 3, + actions: [{ key: 'canReturn', value: 'true' }], + product: { ean: '1122334455', format: 'AU', formatDetails: 'Audio' }, + quantity: { quantity: 1 }, + }, +}; + +describe('ReturnProcessStore', () => { + const createService = createServiceFactory({ + service: ReturnProcessStore, + mocks: [IDBStorageProvider, ProcessService], + }); + + describe('Initialization', () => { + it('should create an instance of ReturnProcessStore', () => { + const spectator = createService(); + expect(spectator.service).toBeTruthy(); + }); + + it('should have a nextId computed property', () => { + const spectator = createService(); + expect(spectator.service.nextId()).toBe(1); // Assuming no entities exist initially + }); + }); + + describe('Entity Management', () => { + it('should remove all entities by process id', () => { + const spectator = createService(); + const store = spectator.service; + + patchState( + store as any, + setAllEntities([ + { id: 1, processId: 1, name: 'Process 1' }, + { id: 2, processId: 2, name: 'Process 2' }, + { id: 3, processId: 1, name: 'Process 3' }, + ]), + ); + + store.removeAllEntitiesByProcessId(1); + expect(store.entities()).toHaveLength(1); + expect(store.entities()[0].processId).toBe(2); + }); + + it('should set an answer for a given entity', () => { + const spectator = createService(); + const store = spectator.service; + + patchState(store as any, setAllEntities([{ id: 1, processId: 1, answers: {} }])); + + store.setAnswer(1, 'question1', 'answer1'); + expect(store.entityMap()[1].answers['question1']).toBe('answer1'); + }); + + it('should remove an answer for a given entity', () => { + const spectator = createService(); + const store = spectator.service; + + patchState( + store as any, + setAllEntities([{ id: 1, processId: 1, answers: { question1: 'answer1' } }]), + ); + + store.removeAnswer(1, 'question1'); + expect(store.entityMap()[1].answers['question1']).toBeUndefined(); + }); + + it('should set a product category for a given entity', () => { + const spectator = createService(); + const store = spectator.service; + + patchState( + store as any, + setAllEntities([{ id: 1, processId: 1, productCategory: undefined }]), + ); + + store.setProductCategory(1, 'Electronics'); + expect(store.entityMap()[1].productCategory).toBe('Electronics'); + }); + }); + + describe('Process Management', () => { + it('should initialize a new return process', () => { + const spectator = createService(); + const store = spectator.service; + + store.startProcess({ + processId: 1, + receiptId: 123, + items: [TEST_ITEMS[1], TEST_ITEMS[3]], + }); + + expect(store.entities()).toHaveLength(2); + }); + + it('should throw an error if no returnable items are found', () => { + const spectator = createService(); + const store = spectator.service; + + expect(() => { + store.startProcess({ + processId: 1, + receiptId: 123, + items: [TEST_ITEMS[2]], + }); + }).toThrow(ReturnProcessNoReturnableItemsError); + }); + + it('should throw an error if the number of returnable items does not match the total items', () => { + const spectator = createService(); + const store = spectator.service; + + expect(() => { + store.startProcess({ + processId: 1, + receiptId: 123, + items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]], + }); + }).toThrow(ReturnProcessMismatchReturnableItemsError); + }); + }); +}); diff --git a/libs/oms/data-access/src/lib/return-process.store.ts b/libs/oms/data-access/src/lib/return-process.store.ts index dbc9eaba7..9381d3120 100644 --- a/libs/oms/data-access/src/lib/return-process.store.ts +++ b/libs/oms/data-access/src/lib/return-process.store.ts @@ -4,13 +4,45 @@ import { IDBStorageProvider, withStorage } from '@isa/core/storage'; import { computed, effect, inject } from '@angular/core'; import { ProcessService } from '@isa/core/process'; import { ReceiptItem, ReturnProcess } from './models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + ReturnProcessMismatchReturnableItemsError, + ReturnProcessNoReturnableItemsError, +} from './errors'; +/** + * Interface representing the parameters required to start a return process. + */ export type StartProcess = { processId: number; receiptId: number; items: ReceiptItem[]; }; +/** + * Store for managing return process entities. + * + * This store is responsible for handling the state and behavior of return process entities used in the application. + * It leverages persistence with the IDBStorageProvider, supports entity management operations, and includes computed + * properties and hooks to synchronize state based on external dependencies. + * + * Key Features: + * - Entity Management: Maintains return process entities using methods to add, update, and remove them. + * - Persistence: Automatically stores state changes via the configured IDBStorageProvider. + * - Computed Properties: Includes a computed "nextId" for generating new unique entity identifiers. + * - Process Actions: Provides methods to handle various actions on return process entities, such as: + * - removeAllEntitiesByProcessId: Removes entities not matching specified process IDs. + * - setAnswer and removeAnswer: Manage answers associated with specific questions for entities. + * - setProductCategory: Assigns a product category to an entity. + * - startProcess: Initializes a new return process by filtering receipt items, validating them, and creating a new set of entities. + * + * Hooks: + * - onInit: Ensures that any entities with process IDs not recognized by the ProcessService are automatically cleaned up. + * + * Exceptions: + * - Throws a NoReturnableItemsError if no returnable items are identified. + * - Throws a MismatchReturnableItemsError if the number of returnable items does not match the expected count. + */ export const ReturnProcessStore = signalStore( { providedIn: 'root' }, withStorage('return-process', IDBStorageProvider), @@ -19,15 +51,27 @@ export const ReturnProcessStore = signalStore( nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1), })), withMethods((store) => ({ + /** + * Removes all entities associated with the specified process IDs from the store. + * @param processIds - The process IDs to filter entities by. + * @returns void + */ removeAllEntitiesByProcessId: (...processIds: number[]) => { const entitiesToRemove = store .entities() - .filter((entity) => processIds.includes(entity.processId)); + .filter((entity) => !processIds.includes(entity.processId)); patchState(store, setAllEntities(entitiesToRemove)); store.storeState(); }, - setAnswer: (id: number, question: string, answer: unknown) => { + /** + * Sets an answer for a specific question associated with an entity. + * @param id - The ID of the entity to update. + * @param question - The question associated with the answer. + * @param answer - The answer to set for the specified question. + * @returns void + */ + setAnswer: (id: number, question: string, answer: T) => { const entity = store.entityMap()[id]; if (entity) { const answers = { ...entity.answers, [question]: answer }; @@ -35,6 +79,13 @@ export const ReturnProcessStore = signalStore( store.storeState(); } }, + + /** + * Removes an answer for a specific question associated with an entity. + * @param id - The ID of the entity to update. + * @param question - The question associated with the answer to remove. + * @returns void + */ removeAnswer: (id: number, question: string) => { const entity = store.entityMap()[id]; if (entity) { @@ -45,6 +96,13 @@ export const ReturnProcessStore = signalStore( } }, + /** + * Sets the product category for a specific entity. + * If the entity does not have an existing return question, for its product category, + * it will be set to the provided category. + * @param id - The ID of the entity to update. + * @param category - The product category to set for the entity. + */ setProductCategory: (id: number, category: string | undefined) => { const entity = store.entityMap()[id]; if (entity) { @@ -54,21 +112,41 @@ export const ReturnProcessStore = signalStore( }, })), withMethods((store) => ({ + /** + * Initializes a new return process by removing previous entities for the given process id, + * then filtering and validating the receipt items, and finally creating new return process entities. + * + * @param params - The configuration for starting a new return process. + * @param params.processId - The unique identifier for the return process. + * @param params.receiptId - The identifier for the associated receipt. + * @param params.items - An array of receipt items to be processed. + * + * @throws {Error} Throws an error if no returnable items are found. + * @throws {Error} Throws an error if the number of returnable items does not match the total items. + */ startProcess: (params: StartProcess) => { + // Remove existing entities related to the process to start fresh. store.removeAllEntitiesByProcessId(params.processId); const entities: ReturnProcess[] = []; const nextId = store.nextId(); const returnableItems = params.items.filter((item) => - item.actions?.some((a) => a.key === 'canReturn' && Boolean(a.value)), + item.actions?.some((a) => a.key === 'canReturn' && coerceBooleanProperty(a.value)), ); if (returnableItems.length === 0) { - throw new Error('No returnable items found'); + throw new ReturnProcessNoReturnableItemsError({ + processId: params.processId, + receiptId: params.receiptId, + }); } if (returnableItems.length !== params.items.length) { - throw new Error('Not all items are returnable'); + throw new ReturnProcessMismatchReturnableItemsError({ + processId: params.processId, + items: params.items.length, + returnableItems: returnableItems.length, + }); } for (let i = 0; i < params.items.length; i++) { diff --git a/package-lock.json b/package-lock.json index 3f94e5e39..5d49bc509 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@ngrx/effects": "19.0.1", "@ngrx/entity": "19.0.1", "@ngrx/operators": "19.0.1", + "@ngrx/signals": "^19.0.1", "@ngrx/store": "19.0.1", "@ngrx/store-devtools": "19.0.1", "angular-oauth2-oidc": "^17.0.2", @@ -53,7 +54,6 @@ "@angular/pwa": "^19.2.0", "@eslint/js": "^9.8.0", "@ngneat/spectator": "^19.0.0", - "@ngrx/signals": "^19.0.1", "@nx/angular": "20.4.6", "@nx/eslint": "20.4.6", "@nx/eslint-plugin": "20.4.6", @@ -7133,7 +7133,6 @@ "version": "19.0.1", "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.0.1.tgz", "integrity": "sha512-e9cGgF//tIyN1PKDDcBQkI0csxRcw4r9ezTtDzQpM2gPU5frD9JxaW/YU5gM02ZMl97bUMoI82fBtnDN0RtyWg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.3.0" diff --git a/package.json b/package.json index 93b3b0f89..f67f82390 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@ngrx/effects": "19.0.1", "@ngrx/entity": "19.0.1", "@ngrx/operators": "19.0.1", + "@ngrx/signals": "^19.0.1", "@ngrx/store": "19.0.1", "@ngrx/store-devtools": "19.0.1", "angular-oauth2-oidc": "^17.0.2", @@ -64,7 +65,6 @@ "@angular/pwa": "^19.2.0", "@eslint/js": "^9.8.0", "@ngneat/spectator": "^19.0.0", - "@ngrx/signals": "^19.0.1", "@nx/angular": "20.4.6", "@nx/eslint": "20.4.6", "@nx/eslint-plugin": "20.4.6",