diff --git a/.dockerignore b/.dockerignore index 0e5e8c15f9..8d37eb3b77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,7 @@ /.nb-gradle /nbproject/ /.nvmrc +/.vim/ /.angular/cache **/*.log /src/environments/environment.local.ts diff --git a/.gitignore b/.gitignore index 40d2ef6cfd..b9806fca76 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ node_modules /.nb-gradle /nbproject/ /.nvmrc +/.vim/ # misc /.angular/cache diff --git a/server.ts b/server.ts index 984c42f264..b555348c17 100644 --- a/server.ts +++ b/server.ts @@ -187,6 +187,7 @@ export function app() { '/login', '/logout', '/forgotPassword', + '/gdpr-requests', '/contact', ], }) diff --git a/src/app/core/configuration.module.ts b/src/app/core/configuration.module.ts index f1dd52b522..4ab85a0049 100644 --- a/src/app/core/configuration.module.ts +++ b/src/app/core/configuration.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { SPECIAL_HTTP_ERROR_HANDLER } from './interceptors/icm-error-mapper.interceptor'; import { createPaymentErrorHandler } from './utils/http-error/create-payment.error-handler'; +import { dataRequestErrorHandler } from './utils/http-error/data-request.error-handler'; import { editPasswordErrorHandler } from './utils/http-error/edit-password.error-handler'; import { LoginUserErrorHandler } from './utils/http-error/login-user.error-handler'; import { requestReminderErrorHandler } from './utils/http-error/request-reminder.error-handler'; @@ -12,6 +13,7 @@ import { updatePasswordErrorHandler } from './utils/http-error/update-password.e { provide: SPECIAL_HTTP_ERROR_HANDLER, useValue: updatePasswordErrorHandler, multi: true }, { provide: SPECIAL_HTTP_ERROR_HANDLER, useClass: LoginUserErrorHandler, multi: true }, { provide: SPECIAL_HTTP_ERROR_HANDLER, useValue: requestReminderErrorHandler, multi: true }, + { provide: SPECIAL_HTTP_ERROR_HANDLER, useValue: dataRequestErrorHandler, multi: true }, { provide: SPECIAL_HTTP_ERROR_HANDLER, useValue: editPasswordErrorHandler, multi: true }, { provide: SPECIAL_HTTP_ERROR_HANDLER, useValue: createPaymentErrorHandler, multi: true }, ], diff --git a/src/app/core/facades/account.facade.ts b/src/app/core/facades/account.facade.ts index c6678e07ed..6ba6940542 100644 --- a/src/app/core/facades/account.facade.ts +++ b/src/app/core/facades/account.facade.ts @@ -21,6 +21,11 @@ import { loadAddresses, } from 'ish-core/store/customer/addresses'; import { getUserRoles } from 'ish-core/store/customer/authorization'; +import { + firstGDPRDataRequest, + getDataRequestError, + getDataRequestLoading, +} from 'ish-core/store/customer/data-requests'; import { getOrders, getOrdersLoading, getSelectedOrder, loadOrders } from 'ish-core/store/customer/orders'; import { cancelRegistration, @@ -233,6 +238,13 @@ export class AccountFacade { this.store.dispatch(deleteCustomerAddress({ addressId })); } + // DATA REQUESTS + + dataRequestLoading$ = this.store.pipe(select(getDataRequestLoading)); + dataRequestError$ = this.store.pipe(select(getDataRequestError)); + // boolean to check wether the GDPR data request is dispatched for the first time + isFirstGDPRDataRequest$ = this.store.pipe(select(firstGDPRDataRequest)); + // SSO ssoRegistrationError$ = this.store.pipe(select(getSsoRegistrationError)); diff --git a/src/app/core/models/data-request/data-request.interface.ts b/src/app/core/models/data-request/data-request.interface.ts new file mode 100644 index 0000000000..bb2c9e1f4a --- /dev/null +++ b/src/app/core/models/data-request/data-request.interface.ts @@ -0,0 +1,19 @@ +/** + * response data type for confirm data request + */ +export interface DataRequestData { + data: [ + { + hash: string; + } + ]; + infos?: DataRequestInfo[]; +} + +export interface DataRequestInfo { + causes?: [ + { + code: string; + } + ]; +} diff --git a/src/app/core/models/data-request/data-request.mapper.spec.ts b/src/app/core/models/data-request/data-request.mapper.spec.ts new file mode 100644 index 0000000000..b82a8abee4 --- /dev/null +++ b/src/app/core/models/data-request/data-request.mapper.spec.ts @@ -0,0 +1,20 @@ +import { DataRequestData, DataRequestInfo } from './data-request.interface'; +import { DataRequestMapper } from './data-request.mapper'; + +describe('Data Request Mapper', () => { + describe('fromData', () => { + it(`should return DataRequestConfirmation information when getting DataRequestData`, () => { + const payloadData = { + infos: [{ causes: [{ code: 'already confirmed' }] } as DataRequestInfo], + } as DataRequestData; + + const dataRequest = DataRequestMapper.fromData(payloadData); + + expect(dataRequest).toMatchInlineSnapshot(` + Object { + "infoCode": "already confirmed", + } + `); + }); + }); +}); diff --git a/src/app/core/models/data-request/data-request.mapper.ts b/src/app/core/models/data-request/data-request.mapper.ts new file mode 100644 index 0000000000..80797868fe --- /dev/null +++ b/src/app/core/models/data-request/data-request.mapper.ts @@ -0,0 +1,13 @@ +import { DataRequestData } from './data-request.interface'; +import { DataRequestConfirmation } from './data-request.model'; + +export class DataRequestMapper { + /** + * Map data request data to data request confirmation information + */ + static fromData(data: DataRequestData): DataRequestConfirmation { + return { + infoCode: data?.infos[0]?.causes[0]?.code, + }; + } +} diff --git a/src/app/core/models/data-request/data-request.model.ts b/src/app/core/models/data-request/data-request.model.ts new file mode 100644 index 0000000000..ac5f800d15 --- /dev/null +++ b/src/app/core/models/data-request/data-request.model.ts @@ -0,0 +1,8 @@ +export interface DataRequest { + requestID: string; + hash: string; +} + +export interface DataRequestConfirmation { + infoCode: string; +} diff --git a/src/app/core/services/data-requests/data-requests.service.spec.ts b/src/app/core/services/data-requests/data-requests.service.spec.ts new file mode 100644 index 0000000000..19c78b9d2f --- /dev/null +++ b/src/app/core/services/data-requests/data-requests.service.spec.ts @@ -0,0 +1,61 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; + +import { DataRequestData, DataRequestInfo } from 'ish-core/models/data-request/data-request.interface'; +import { DataRequest } from 'ish-core/models/data-request/data-request.model'; +import { ApiService } from 'ish-core/services/api/api.service'; + +import { DataRequestsService } from './data-requests.service'; + +describe('Data Requests Service', () => { + let apiServiceMock: ApiService; + let dataRequestsService: DataRequestsService; + + beforeEach(() => { + apiServiceMock = mock(ApiService); + TestBed.configureTestingModule({ + providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }], + }); + dataRequestsService = TestBed.inject(DataRequestsService); + }); + + it('should be created', () => { + expect(dataRequestsService).toBeTruthy(); + }); + + describe('Confirm a data request', () => { + it('should return an error when called with undefined', done => { + when(apiServiceMock.put(anything(), anything())).thenReturn(of({})); + + dataRequestsService.confirmGDPRDataRequest(undefined).subscribe({ + next: fail, + error: err => { + expect(err).toMatchInlineSnapshot(`[Error: confirmGDPRDataRequest() called without data body]`); + done(); + }, + }); + + verify(apiServiceMock.put(anything(), anything())).never(); + }); + + it("should confirm data request when 'confirmDataRequest' is called", done => { + const requestData = { + requestID: 'test_ID', + hash: 'test_hash', + } as DataRequest; + const payloadData = { + infos: [{ causes: [{ code: 'already confirmed' }] } as DataRequestInfo], + } as DataRequestData; + + when(apiServiceMock.put(anything(), anything(), anything())).thenReturn(of(payloadData)); + + dataRequestsService.confirmGDPRDataRequest(requestData).subscribe(payload => { + verify(apiServiceMock.put('gdpr-requests/test_ID/confirmations', anything(), anything())).once(); + expect(capture(apiServiceMock.put).last()[0]).toMatchInlineSnapshot(`"gdpr-requests/test_ID/confirmations"`); + expect(payload).toHaveProperty('infoCode', 'already confirmed'); + done(); + }); + }); + }); +}); diff --git a/src/app/core/services/data-requests/data-requests.service.ts b/src/app/core/services/data-requests/data-requests.service.ts new file mode 100644 index 0000000000..74d79a0cfe --- /dev/null +++ b/src/app/core/services/data-requests/data-requests.service.ts @@ -0,0 +1,37 @@ +import { HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, throwError } from 'rxjs'; + +import { DataRequestData } from 'ish-core/models/data-request/data-request.interface'; +import { DataRequestMapper } from 'ish-core/models/data-request/data-request.mapper'; +import { DataRequest, DataRequestConfirmation } from 'ish-core/models/data-request/data-request.model'; +import { ApiService } from 'ish-core/services/api/api.service'; + +@Injectable({ providedIn: 'root' }) +export class DataRequestsService { + constructor(private apiService: ApiService) {} + + /** + * Confirmation of a GDPR data request with corresponding request id and hash. + * + * @param data The DataRequest model includes request id and hash. + * @returns The enriched DataRequest model includes additional response status and code of the request. + */ + confirmGDPRDataRequest(data: DataRequest): Observable { + if (!data) { + return throwError(() => new Error('confirmGDPRDataRequest() called without data body')); + } + + const dataRequestHeaderV1 = new HttpHeaders() + .set('content-type', 'application/json') + .set('Accept', 'application/vnd.intershop.gdpr.v1+json'); + + return this.apiService + .put( + `gdpr-requests/${data.requestID}/confirmations`, + { hash: data.hash }, + { headers: dataRequestHeaderV1 } + ) + .pipe(map(payload => DataRequestMapper.fromData(payload))); + } +} diff --git a/src/app/core/store/customer/customer-store.module.ts b/src/app/core/store/customer/customer-store.module.ts index dcf29c4c7d..fe56208bf5 100644 --- a/src/app/core/store/customer/customer-store.module.ts +++ b/src/app/core/store/customer/customer-store.module.ts @@ -17,6 +17,8 @@ import { BasketValidationEffects } from './basket/basket-validation.effects'; import { BasketEffects } from './basket/basket.effects'; import { basketReducer } from './basket/basket.reducer'; import { CustomerState } from './customer-store'; +import { DataRequestsEffects } from './data-requests/data-requests.effects'; +import { dataRequestsReducer } from './data-requests/data-requests.reducer'; import { OrdersEffects } from './orders/orders.effects'; import { ordersReducer } from './orders/orders.reducer'; import { OrganizationManagementEffects } from './organization-management/organization-management.effects'; @@ -33,6 +35,7 @@ const customerReducers: ActionReducerMap = { basket: basketReducer, authorization: authorizationReducer, ssoRegistration: ssoRegistrationReducer, + dataRequests: dataRequestsReducer, }; const customerEffects = [ @@ -49,6 +52,7 @@ const customerEffects = [ OrganizationManagementEffects, RequisitionManagementEffects, SsoRegistrationEffects, + DataRequestsEffects, ]; @Injectable() diff --git a/src/app/core/store/customer/customer-store.spec.ts b/src/app/core/store/customer/customer-store.spec.ts index 07ee5b9b0a..54d957ee63 100644 --- a/src/app/core/store/customer/customer-store.spec.ts +++ b/src/app/core/store/customer/customer-store.spec.ts @@ -17,6 +17,7 @@ import { BasketService } from 'ish-core/services/basket/basket.service'; import { CategoriesService } from 'ish-core/services/categories/categories.service'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; import { CountryService } from 'ish-core/services/country/country.service'; +import { DataRequestsService } from 'ish-core/services/data-requests/data-requests.service'; import { FilterService } from 'ish-core/services/filter/filter.service'; import { OrderService } from 'ish-core/services/order/order.service'; import { PaymentService } from 'ish-core/services/payment/payment.service'; @@ -140,6 +141,7 @@ describe('Customer Store', () => { const userServiceMock = mock(UserService); when(userServiceMock.signInUser(anything())).thenReturn(of({ customer, user, pgid })); + const dataRequestsServiceMock = mock(DataRequestsService); const filterServiceMock = mock(FilterService); const orderServiceMock = mock(OrderService); const authorizationServiceMock = mock(AuthorizationService); @@ -170,6 +172,7 @@ describe('Customer Store', () => { { provide: BasketService, useFactory: () => instance(basketServiceMock) }, { provide: CategoriesService, useFactory: () => instance(categoriesServiceMock) }, { provide: CookiesService, useFactory: () => instance(mock(CookiesService)) }, + { provide: DataRequestsService, useFactory: () => instance(dataRequestsServiceMock) }, { provide: FilterService, useFactory: () => instance(filterServiceMock) }, { provide: OrderService, useFactory: () => instance(orderServiceMock) }, { provide: PaymentService, useFactory: () => instance(mock(PaymentService)) }, diff --git a/src/app/core/store/customer/customer-store.ts b/src/app/core/store/customer/customer-store.ts index e4c7649e0f..84c00b8663 100644 --- a/src/app/core/store/customer/customer-store.ts +++ b/src/app/core/store/customer/customer-store.ts @@ -4,6 +4,7 @@ import { Authorization } from 'ish-core/models/authorization/authorization.model import { AddressesState } from './addresses/addresses.reducer'; import { BasketState } from './basket/basket.reducer'; +import { DataRequestsState } from './data-requests/data-requests.reducer'; import { OrdersState } from './orders/orders.reducer'; import { SsoRegistrationState } from './sso-registration/sso-registration.reducer'; import { UserState } from './user/user.reducer'; @@ -15,6 +16,7 @@ export interface CustomerState { basket: BasketState; authorization: Authorization; ssoRegistration: SsoRegistrationState; + dataRequests: DataRequestsState; } export const getCustomerState = createFeatureSelector('_customer'); diff --git a/src/app/core/store/customer/data-requests/data-requests.actions.ts b/src/app/core/store/customer/data-requests/data-requests.actions.ts new file mode 100644 index 0000000000..827064c548 --- /dev/null +++ b/src/app/core/store/customer/data-requests/data-requests.actions.ts @@ -0,0 +1,19 @@ +import { createAction } from '@ngrx/store'; + +import { DataRequest, DataRequestConfirmation } from 'ish-core/models/data-request/data-request.model'; +import { httpError, payload } from 'ish-core/utils/ngrx-creators'; + +export const confirmGDPRDataRequest = createAction( + '[DataRequest API] Confirm GDPR Data Request', + payload<{ data: DataRequest }>() +); + +export const confirmGDPRDataRequestSuccess = createAction( + '[DataRequest API] Confirm GDPR Data Request Success', + payload() +); + +export const confirmGDPRDataRequestFail = createAction( + '[DataRequest API] Confirm GDPR Data Request Failed', + httpError() +); diff --git a/src/app/core/store/customer/data-requests/data-requests.effects.spec.ts b/src/app/core/store/customer/data-requests/data-requests.effects.spec.ts new file mode 100644 index 0000000000..5dd1259aec --- /dev/null +++ b/src/app/core/store/customer/data-requests/data-requests.effects.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable, of, throwError } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { DataRequest, DataRequestConfirmation } from 'ish-core/models/data-request/data-request.model'; +import { DataRequestsService } from 'ish-core/services/data-requests/data-requests.service'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { routerTestNavigatedAction } from 'ish-core/utils/dev/routing'; + +import { + confirmGDPRDataRequest, + confirmGDPRDataRequestFail, + confirmGDPRDataRequestSuccess, +} from './data-requests.actions'; +import { DataRequestsEffects } from './data-requests.effects'; + +describe('Data Requests Effects', () => { + let actions$: Observable; + let effects: DataRequestsEffects; + let dataRequestsServiceMock: DataRequestsService; + let router: Router; + + const requestID = '0123456789'; + const hash = 'test_hash'; + + const dataRequest = { requestID, hash } as DataRequest; + const dataRequestConfirmation = { infoCode: 'gdpr_request.confirmed.info' } as DataRequestConfirmation; + + beforeEach(() => { + dataRequestsServiceMock = mock(DataRequestsService); + when(dataRequestsServiceMock.confirmGDPRDataRequest(anything())).thenReturn(of(dataRequestConfirmation)); + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([{ path: '**', children: [] }])], + providers: [ + { provide: DataRequestsService, useFactory: () => instance(dataRequestsServiceMock) }, + DataRequestsEffects, + provideMockActions(() => actions$), + ], + }); + + effects = TestBed.inject(DataRequestsEffects); + router = TestBed.inject(Router); + }); + + describe('confirmGDPRDataRequest$', () => { + it('should call the DataRequestsServic for confirmGDPRDataRequest', done => { + const action = confirmGDPRDataRequest({ data: dataRequest }); + actions$ = of(action); + + effects.confirmGDPRDataRequest$.subscribe(() => { + verify(dataRequestsServiceMock.confirmGDPRDataRequest(anything())).once(); + done(); + }); + }); + it('should map to action of type confirmGDPRDataRequestSuccess', () => { + const action = confirmGDPRDataRequest({ data: dataRequest }); + const completion = confirmGDPRDataRequestSuccess(dataRequestConfirmation); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.confirmGDPRDataRequest$).toBeObservable(expected$); + }); + it('should map invalid request to action of type confirmGDPRDataRequestFail', () => { + when(dataRequestsServiceMock.confirmGDPRDataRequest(anything())).thenReturn( + throwError(() => makeHttpError({ message: 'invalid' })) + ); + const action = confirmGDPRDataRequest({ data: dataRequest }); + const error = makeHttpError({ message: 'invalid' }); + const completion = confirmGDPRDataRequestFail({ error }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.confirmGDPRDataRequest$).toBeObservable(expected$); + }); + }); + + describe('routeListenerForDataRequests', () => { + it('should fire confirmGDPRDataRequest when route gdpr-requests is navigated', () => { + router.navigateByUrl('/gdpr-requests'); + + const action = routerTestNavigatedAction({ + routerState: { url: '/gdpr-requests', queryParams: { Hash: hash, PersonalDataRequestID: requestID } }, + }); + actions$ = of(action); + + const completion = confirmGDPRDataRequest({ data: dataRequest }); + actions$ = hot('-a', { a: action }); + const expected$ = cold('-(c)', { c: completion }); + + expect(effects.routeListenerForDataRequests$).toBeObservable(expected$); + }); + }); +}); diff --git a/src/app/core/store/customer/data-requests/data-requests.effects.ts b/src/app/core/store/customer/data-requests/data-requests.effects.ts new file mode 100644 index 0000000000..a3ba3133d8 --- /dev/null +++ b/src/app/core/store/customer/data-requests/data-requests.effects.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { routerNavigatedAction } from '@ngrx/router-store'; +import { concatMap, filter, map } from 'rxjs/operators'; + +import { DataRequestsService } from 'ish-core/services/data-requests/data-requests.service'; +import { mapToRouterState } from 'ish-core/store/core/router'; +import { mapErrorToAction, mapToPayload } from 'ish-core/utils/operators'; + +import { + confirmGDPRDataRequest, + confirmGDPRDataRequestFail, + confirmGDPRDataRequestSuccess, +} from './data-requests.actions'; + +@Injectable() +export class DataRequestsEffects { + constructor(private actions$: Actions, private dataRequestsService: DataRequestsService) {} + + confirmGDPRDataRequest$ = createEffect(() => + this.actions$.pipe( + ofType(confirmGDPRDataRequest), + mapToPayload(), + concatMap(payload => + this.dataRequestsService + .confirmGDPRDataRequest(payload.data) + .pipe(map(confirmGDPRDataRequestSuccess), mapErrorToAction(confirmGDPRDataRequestFail)) + ) + ) + ); + + /** + * Listener for GDPR email routing. If route is called the action {@link confirmGDPRDataRequest} is dispatched. + */ + routeListenerForDataRequests$ = createEffect(() => + this.actions$.pipe( + ofType(routerNavigatedAction), + mapToRouterState(), + filter(routerState => /^\/(gdpr-requests*)/.test(routerState.url)), + map(({ queryParams }) => + confirmGDPRDataRequest({ data: { hash: queryParams.Hash, requestID: queryParams.PersonalDataRequestID } }) + ) + ) + ); +} diff --git a/src/app/core/store/customer/data-requests/data-requests.reducer.ts b/src/app/core/store/customer/data-requests/data-requests.reducer.ts new file mode 100644 index 0000000000..589e3214e7 --- /dev/null +++ b/src/app/core/store/customer/data-requests/data-requests.reducer.ts @@ -0,0 +1,37 @@ +import { createReducer, on } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { setErrorOn, setLoadingOn, unsetLoadingOn } from 'ish-core/utils/ngrx-creators'; + +import { + confirmGDPRDataRequest, + confirmGDPRDataRequestFail, + confirmGDPRDataRequestSuccess, +} from './data-requests.actions'; + +export interface DataRequestsState { + loading: boolean; + error: HttpError; + firstGDPRDataRequest: boolean; +} + +const initialState: DataRequestsState = { + loading: false, + error: undefined, + firstGDPRDataRequest: true, +}; + +export const dataRequestsReducer = createReducer( + initialState, + setLoadingOn(confirmGDPRDataRequest), + unsetLoadingOn(confirmGDPRDataRequestSuccess, confirmGDPRDataRequestFail), + setErrorOn(confirmGDPRDataRequestFail), + + on( + confirmGDPRDataRequestSuccess, + (state, action): DataRequestsState => ({ + ...state, + firstGDPRDataRequest: action.payload.infoCode === 'gdpr_request.confirmed.info', + }) + ) +); diff --git a/src/app/core/store/customer/data-requests/data-requests.selectors.spec.ts b/src/app/core/store/customer/data-requests/data-requests.selectors.spec.ts new file mode 100644 index 0000000000..941b89d9d6 --- /dev/null +++ b/src/app/core/store/customer/data-requests/data-requests.selectors.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; + +import { DataRequest, DataRequestConfirmation } from 'ish-core/models/data-request/data-request.model'; +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; + +import { + confirmGDPRDataRequest, + confirmGDPRDataRequestFail, + confirmGDPRDataRequestSuccess, +} from './data-requests.actions'; +import { firstGDPRDataRequest, getDataRequestError, getDataRequestLoading } from './data-requests.selectors'; + +describe('Data Requests Selectors', () => { + let store$: StoreWithSnapshots; + + const dataRequest = { requestID: '0123456789', hash: 'test_hash' } as DataRequest; + const payloadSuccess = { infoCode: 'gdpr_request.confirmed.info' } as DataRequestConfirmation; + const payloadAlreadyConfirmed = { infoCode: 'already.confirmed' } as DataRequestConfirmation; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), CustomerStoreModule.forTesting('dataRequests')], + providers: [provideStoreSnapshots()], + }); + + store$ = TestBed.inject(StoreWithSnapshots); + }); + + describe('with empty state', () => { + it('should not set status when used', () => { + expect(firstGDPRDataRequest(store$.state)).toBeTruthy(); + expect(getDataRequestLoading(store$.state)).toBeFalsy(); + expect(getDataRequestError(store$.state)).toBeUndefined(); + }); + }); + + describe('loading confirmation', () => { + beforeEach(() => { + store$.dispatch(confirmGDPRDataRequest({ data: dataRequest })); + }); + it('should set the state to loading', () => { + expect(getDataRequestLoading(store$.state)).toBeTruthy(); + }); + + describe('and reporting success', () => { + beforeEach(() => { + store$.dispatch(confirmGDPRDataRequestSuccess(payloadSuccess)); + }); + + it('should set loading to false', () => { + expect(getDataRequestLoading(store$.state)).toBeFalsy(); + expect(firstGDPRDataRequest(store$.state)).toBeTruthy(); + }); + }); + + describe('and reporting already confirmed', () => { + beforeEach(() => { + store$.dispatch(confirmGDPRDataRequestSuccess(payloadAlreadyConfirmed)); + }); + + it('should set loading to false', () => { + expect(getDataRequestLoading(store$.state)).toBeFalsy(); + expect(firstGDPRDataRequest(store$.state)).toBeFalsy(); + }); + }); + + describe('and reporting failure', () => { + beforeEach(() => { + store$.dispatch(confirmGDPRDataRequestFail({ error: makeHttpError({ status: 422, message: 'error' }) })); + }); + + it('should set an error', () => { + expect(getDataRequestLoading(store$.state)).toBeFalsy(); + expect(getDataRequestError(store$.state)).toBeTruthy(); + }); + }); + }); +}); diff --git a/src/app/core/store/customer/data-requests/data-requests.selectors.ts b/src/app/core/store/customer/data-requests/data-requests.selectors.ts new file mode 100644 index 0000000000..bc6594e745 --- /dev/null +++ b/src/app/core/store/customer/data-requests/data-requests.selectors.ts @@ -0,0 +1,11 @@ +import { createSelector } from '@ngrx/store'; + +import { getCustomerState } from 'ish-core/store/customer/customer-store'; + +const getDataRequestsState = createSelector(getCustomerState, state => state.dataRequests); + +export const getDataRequestLoading = createSelector(getDataRequestsState, state => state.loading); + +export const getDataRequestError = createSelector(getDataRequestsState, state => state.error); + +export const firstGDPRDataRequest = createSelector(getDataRequestsState, state => state.firstGDPRDataRequest); diff --git a/src/app/core/store/customer/data-requests/index.ts b/src/app/core/store/customer/data-requests/index.ts new file mode 100644 index 0000000000..1a454b7781 --- /dev/null +++ b/src/app/core/store/customer/data-requests/index.ts @@ -0,0 +1,3 @@ +// API to access ngrx data requests state +export * from './data-requests.actions'; +export * from './data-requests.selectors'; diff --git a/src/app/core/utils/http-error/data-request.error-handler.ts b/src/app/core/utils/http-error/data-request.error-handler.ts new file mode 100644 index 0000000000..1428a37f46 --- /dev/null +++ b/src/app/core/utils/http-error/data-request.error-handler.ts @@ -0,0 +1,15 @@ +import { SpecialHttpErrorHandler } from 'ish-core/interceptors/icm-error-mapper.interceptor'; + +export const dataRequestErrorHandler: SpecialHttpErrorHandler = { + test: (error, request) => error.url.endsWith('/confirmations') && request.method === 'PUT', + map: error => { + switch (error.status) { + case 404: + return { code: 'personal.data.request.confirmation_link_expired.error' }; + case 422: + return { code: 'personal.data.request.unprocessable.error' }; + default: + return { code: 'personal.data.request.server_connection_failed.error' }; + } + }, +}; diff --git a/src/app/pages/app-routing.module.ts b/src/app/pages/app-routing.module.ts index d13e6a687c..f8eaea3c90 100644 --- a/src/app/pages/app-routing.module.ts +++ b/src/app/pages/app-routing.module.ts @@ -114,6 +114,17 @@ const routes: Routes = [ }, }, }, + { + // route for handling confirmation of user data and account deletion requests + path: 'gdpr-requests', + loadChildren: () => import('./data-request/data-request-page.module').then(m => m.DataRequestPageModule), + data: { + meta: { + title: 'personal.data.request.title', + robots: 'noindex, nofollow', + }, + }, + }, { path: 'cookies', loadChildren: () => import('./cookies/cookies-page.module').then(m => m.CookiesPageModule) }, ]; diff --git a/src/app/pages/data-request/data-request-page.component.html b/src/app/pages/data-request/data-request-page.component.html new file mode 100644 index 0000000000..1466bb1858 --- /dev/null +++ b/src/app/pages/data-request/data-request-page.component.html @@ -0,0 +1,22 @@ + + +

+ {{ 'personal.data.request.confirmation.empty.heading' | translate }} +

+
+
+ + +

+ {{ 'personal.data.request.confirmation.success.heading' | translate }} +

+
+
+ +

+ {{ 'personal.data.request.confirmation.confirmed.heading' | translate }} +

+
+
+
+ diff --git a/src/app/pages/data-request/data-request-page.component.spec.ts b/src/app/pages/data-request/data-request-page.component.spec.ts new file mode 100644 index 0000000000..f820d837eb --- /dev/null +++ b/src/app/pages/data-request/data-request-page.component.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { DataRequestPageComponent } from './data-request-page.component'; + +describe('Data Request Page Component', () => { + let component: DataRequestPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let accountFacade: AccountFacade; + + beforeEach(async () => { + accountFacade = mock(AccountFacade); + when(accountFacade.dataRequestError$).thenReturn(undefined); + when(accountFacade.dataRequestLoading$).thenReturn(of(false)); + when(accountFacade.isFirstGDPRDataRequest$).thenReturn(of(true)); + await TestBed.configureTestingModule({ + declarations: [ + DataRequestPageComponent, + MockComponent(ErrorMessageComponent), + MockComponent(LoadingComponent), + MockDirective(ServerHtmlDirective), + ], + imports: [TranslateModule.forRoot()], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DataRequestPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + expect(element.querySelector('h1[data-testing-id=successful-confirmation-title]')).toBeTruthy(); + expect(element.querySelector('h1[data-testing-id=already-confirmed-title]')).toBeFalsy(); + }); + it('should be displayed alternative content if confirmation already confirmed', () => { + when(accountFacade.isFirstGDPRDataRequest$).thenReturn(of(false)); + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + expect(element.querySelector('h1[data-testing-id=successful-confirmation-title]')).toBeFalsy(); + expect(element.querySelector('h1[data-testing-id=already-confirmed-title]')).toBeTruthy(); + }); + + it('should render loading component if landing page is loading', () => { + when(accountFacade.dataRequestLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); + + it('should render error component if confirmation failed', () => { + when(accountFacade.dataRequestError$).thenReturn(of(makeHttpError({ status: 404 }))); + fixture.detectChanges(); + expect(element.querySelector('ish-error-message')).toBeTruthy(); + }); +}); diff --git a/src/app/pages/data-request/data-request-page.component.ts b/src/app/pages/data-request/data-request-page.component.ts new file mode 100644 index 0000000000..abcdde909e --- /dev/null +++ b/src/app/pages/data-request/data-request-page.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +/** + * The data request page handles the interaction for dispatching of a confirmation request triggered via confirmation email link. + */ +@Component({ + selector: 'ish-data-request-page', + templateUrl: './data-request-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DataRequestPageComponent implements OnInit { + loading$: Observable; + error$: Observable; + firstGDPRDataRequest$: Observable; + + constructor(private accountFacade: AccountFacade) {} + + ngOnInit(): void { + this.error$ = this.accountFacade.dataRequestError$; + this.loading$ = this.accountFacade.dataRequestLoading$; + this.firstGDPRDataRequest$ = this.accountFacade.isFirstGDPRDataRequest$; + } +} diff --git a/src/app/pages/data-request/data-request-page.module.ts b/src/app/pages/data-request/data-request-page.module.ts new file mode 100644 index 0000000000..c1e1acb512 --- /dev/null +++ b/src/app/pages/data-request/data-request-page.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { DataRequestPageComponent } from './data-request-page.component'; + +const dataRequestPageRoutes: Routes = [{ path: '**', component: DataRequestPageComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(dataRequestPageRoutes), SharedModule], + declarations: [DataRequestPageComponent], +}) +export class DataRequestPageModule {} diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index ad31399a2e..735f85e8e4 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -836,6 +836,16 @@ "order.tracking.error": "Leider konnte keine Bestellung mit Ihren Daten gefunden werden.", "order_template.create.heading": "Bestellvorlage anlegen", "payment.error.PaymentInstrumentAlreadyExists": "Das Zahlungsmittel konnte nicht angelegt werden. Zahlungsdaten mit den angegebenen Parametern sind bereits vorhanden.", + "personal.data.request.confirmation.confirmed.heading": "Bereits bestätigt", + "personal.data.request.confirmation.confirmed.message": "

Sie haben Ihre Anfrage bereits bestätigt und sie wird bearbeitet.

Bei Fragen wenden Sie sich bitte an unseren Kundenservice unter 1-800-xxx-xxx oder schreiben Sie uns eine Nachricht: Kundenservice.

", + "personal.data.request.confirmation.empty.heading": "Anfrage zu persönlichen Daten", + "personal.data.request.confirmation.empty.message": "

Bei Fragen wenden Sie sich bitte an unseren Kundenservice unter 1-800-xxx-xxx oder schreiben Sie uns eine Nachricht: Kundenservice.

", + "personal.data.request.confirmation.success.heading": "Vielen Dank für die Anfrage Ihrer persönlichen Daten.", + "personal.data.request.confirmation.success.message": "

Ihre Anfrage wurde erfolgreich an unseren Kundenservice weitergeleitet.

Wir werden uns mit Ihnen in Verbindung setzen, wenn es Fragen oder Bedenken bezüglich Ihrer Anfrage gibt. Sie erhalten von dem Shop eine Bestätigungs-E-Mail mit weiteren Details zu Ihrer Anfrage, sobald diese geprüft und bearbeitet wurde.

", + "personal.data.request.confirmation_link_expired.error": "Ihr Link zur Datenanfrage ist abgelaufen. Wir bitten um Entschuldigung. Wenden Sie sich bitte an Ihren Shop, um eine neue Anfrage für persönliche Daten zu erhalten.", + "personal.data.request.server_connection_failed.error": "Serververbindung fehlgeschlagen. Wir bitten um Entschuldigung. Bitte versuchen Sie es später erneut.", + "personal.data.request.title": "Anfrage zu persönlichen Daten", + "personal.data.request.unprocessable.error": "Ihre Datenanfrage konnte nicht bearbeitet werden. Wir bitten um Entschuldigung. Wenden Sie sich bitte an Ihren Shop, um eine neue Anfrage für persönliche Daten zu erhalten.", "product.add_to_cart.link": "In den Warenkorb", "product.add_to_cart.retailset.link": "Artikel in den Warenkorb legen", "product.add_to_wishlist.link": "Auf die Wunschliste", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 8dd2cb949c..0c2cb9f3bd 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -836,6 +836,16 @@ "order.tracking.error": "Unfortunately, we could not locate an order with the information you provided.", "order_template.create.heading": "Create Order Template", "payment.error.PaymentInstrumentAlreadyExists": "The payment instrument could not be created. Payment data with the given parameters already exists.", + "personal.data.request.confirmation.confirmed.heading": "Already Verified", + "personal.data.request.confirmation.confirmed.message": "

You have already verified your request and it is being processed.

For any questions, please contact our Customer Service at 1-800-xxx-xxx or write a message to our Customer Service.

", + "personal.data.request.confirmation.empty.heading": "Personal Data Request", + "personal.data.request.confirmation.empty.message": "

For any questions, please contact our Customer Service at 1-800-xxx-xxx or write a message to our Customer Service.

", + "personal.data.request.confirmation.success.heading": "Thank You for Your Personal Data Request", + "personal.data.request.confirmation.success.message": "

Your request was successfully submitted to our Customer Service department.

We will contact you if there are any questions or concerns with your request. You will receive a confirmation e-mail from the shop with more details about your request once it has been reviewed and processed.

", + "personal.data.request.confirmation_link_expired.error": "Your data request link is expired. We apologize for the inconvenience. Please contact your shop to get a new personal data request.", + "personal.data.request.server_connection_failed.error": "Server connection failed. We apologize for the inconvenience. Please try again later.", + "personal.data.request.title": "Personal Data Request", + "personal.data.request.unprocessable.error": "Your data request could not be processed. We apologize for the inconvenience. Please contact your shop to get a new personal data request.", "product.add_to_cart.link": "Add to Cart", "product.add_to_cart.retailset.link": "Add item(s) to Cart", "product.add_to_wishlist.link": "Add to Wish List", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index 2cd4371802..57c599a26d 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -836,6 +836,16 @@ "order.tracking.error": "Malheureusement, nous n’avons pas pu localiser de commande avec les informations que vous nous avez fournies.", "order_template.create.heading": "Créer un modèle de commande", "payment.error.PaymentInstrumentAlreadyExists": "Le moyen de paiement n’a pas pu être mis en place. Les données de paiement avec les paramètres fournis existent déjà.", + "personal.data.request.confirmation.confirmed.heading": "Déjà vérifiée", + "personal.data.request.confirmation.confirmed.message": "

Vous avez déjà vérifié votre demande et elle est en cours de traitement.

Pour toute question, veuillez contacter notre service client au 1-800-xxx-xxx ou écrivez un message par Service Client.

", + "personal.data.request.confirmation.empty.heading": "Demande de données personnelles", + "personal.data.request.confirmation.empty.message": "

Pour toute question, veuillez contacter notre service client au 1-800-xxx-xxx ou écrivez un message par Service Client.

", + "personal.data.request.confirmation.success.heading": "Merci de votre demande de données personnelles.", + "personal.data.request.confirmation.success.message": "

Votre demande a été soumise à notre service client.

Nous vous contacterons s’il y a des questions ou des préoccupations concernant votre demande. Vous recevrez un courriel de confirmation du magasin avec plus de détails à propos de votre demande une fois qu’elle aura été examinée et traitée.

", + "personal.data.request.confirmation_link_expired.error": "Votre lien de demande de données a expiré. Nous nous excusons pour les inconvénients. Veuillez contacter votre magasin pour obtenir une nouvelle demande de données personnelles.", + "personal.data.request.server_connection_failed.error": "La connexion au serveur a échoué. Nous nous excusons pour les inconvénients. Veuillez réessayer plus tard.", + "personal.data.request.title": "Demande de données personnelles", + "personal.data.request.unprocessable.error": "Votre demande de données n’a pas pu être traitée. Nous nous excusons pour les inconvénients. Veuillez contacter votre magasin pour obtenir une nouvelle demande de données personnelles.", "product.add_to_cart.link": "Ajouter au panier", "product.add_to_cart.retailset.link": "Ajouter les articles au panier", "product.add_to_wishlist.link": "Ajouter à la liste de souhaits",