mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
✨ Enhance error handling and storage utilities
Improved error handling and updated storage utilities for better performance. - ✨ **Feature**: Added custom error classes for return process errors - 🛠️ **Refactor**: Updated hash function to handle strings and objects - 🛠️ **Refactor**: Enhanced storage key generation with user context - ⚙️ **Config**: Updated VSCode settings and package dependencies
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -91,5 +91,5 @@
|
||||
{
|
||||
"file": "docs/guidelines/testing.md"
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<StorageProvider>) {
|
||||
return signalStoreFeature(
|
||||
withMethods((store, storage = injectStorage(storageProvider)) => ({
|
||||
storeState: () => {
|
||||
const state = getState(store);
|
||||
storage.set(storageKey, state);
|
||||
},
|
||||
storeState: rxMethod<void>(
|
||||
pipe(
|
||||
debounceTime(1000),
|
||||
switchMap(() => storage.set(storageKey, getState(store))),
|
||||
),
|
||||
),
|
||||
restoreState: async () => {
|
||||
const data = await storage.get(storageKey);
|
||||
if (data) {
|
||||
|
||||
@@ -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<T>(token: string | object, value: T): Promise<void> {
|
||||
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<T>(token: string | object, schema?: z.ZodType<T>): Promise<T | unknown> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
libs/oms/data-access/src/lib/errors/index.ts
Normal file
2
libs/oms/data-access/src/lib/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './oms.error';
|
||||
export * from './return-process.error';
|
||||
5
libs/oms/data-access/src/lib/errors/oms.error.ts
Normal file
5
libs/oms/data-access/src/lib/errors/oms.error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export abstract class OmsError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
36
libs/oms/data-access/src/lib/errors/return-process.error.ts
Normal file
36
libs/oms/data-access/src/lib/errors/return-process.error.ts
Normal file
@@ -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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
148
libs/oms/data-access/src/lib/return-process.store.spec.ts
Normal file
148
libs/oms/data-access/src/lib/return-process.store.spec.ts
Normal file
@@ -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<number, ReturnProcess['receiptItem']> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: <T>(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++) {
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user