Skip to content

Commit

Permalink
feat: newsletter subscription (#1523)
Browse files Browse the repository at this point in the history
Co-authored-by: Lucas Hengelhaupt <[email protected]>
Co-authored-by: Silke <[email protected]>
Co-authored-by: MGlatter <[email protected]>
Co-authored-by: Stefan Hauke <[email protected]>
  • Loading branch information
5 people authored Feb 27, 2024
1 parent 1903d09 commit 68c0bc5
Show file tree
Hide file tree
Showing 40 changed files with 880 additions and 63 deletions.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ kb_sync_latest_only
- [Guide - Monitoring with Prometheus](./guides/prometheus-monitoring.md)
- [Guide - Store Locator with Google Maps](./guides/store-locator.md)
- [Guide - Address Check with Address Doctor](./guides/address-doctor.md)
- [Guide - E-Mail Marketing/Newsletter Subscription](./guides/newsletter-subscription.md)
26 changes: 26 additions & 0 deletions docs/guides/newsletter-subscription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!--
kb_guide
kb_pwa
kb_everyone
kb_sync_latest_only
-->

# E-Mail Marketing/Newsletter Subscription

## Introduction

In the PWA registered users can subscribe to and unsubscribe from a newsletter service.

## Configuration

To enable this feature, an e-mail marketing provider has to be configured in Intershop Commerce Management under **Channel Preferences** | **E-mail Marketing**.
The PWA receives information about the e-mail provider configuration via the `/configurations` REST call under `marketing.newsletterSubscriptionEnabled`.

## Storefront

If the newsletter subscription feature is enabled, an additional checkbox is displayed on the registration page and on the account profile page that enables the user to subscribe to or unsubscribe from the newsletter service.

## Further References

- [Intershop Knowledge Base | Concept - E-Mail Marketing / Newsletter Subscription](https://support.intershop.com/kb/index.php/Display/2G9985)
- [Intershop Knowledge Base | Cookbook - E-Mail Marketing / Newsletter Subscription](https://support.intershop.com/kb/index.php/Display/30973Y)
29 changes: 28 additions & 1 deletion src/app/core/facades/account.facade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Subject } from 'rxjs';
import { Observable, Subject, of } from 'rxjs';
import { map, switchMap, take, tap } from 'rxjs/operators';

import { Address } from 'ish-core/models/address/address.model';
Expand All @@ -13,6 +13,7 @@ import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-in
import { User } from 'ish-core/models/user/user.model';
import { OrderListQuery } from 'ish-core/services/order/order.service';
import { MessagesPayloadType } from 'ish-core/store/core/messages';
import { getServerConfigParameter } from 'ish-core/store/core/server-config';
import {
createCustomerAddress,
deleteCustomerAddress,
Expand Down Expand Up @@ -48,6 +49,7 @@ import {
getCustomerApprovalEmail,
getLoggedInCustomer,
getLoggedInUser,
getNewsletterSubscriptionStatus,
getPasswordReminderError,
getPasswordReminderSuccess,
getPriceDisplayType,
Expand All @@ -68,6 +70,7 @@ import {
updateUserPassword,
updateUserPasswordByPasswordReminder,
updateUserPreferredPayment,
userNewsletterActions,
} from 'ish-core/store/customer/user';
import { whenTruthy } from 'ish-core/utils/operators';

Expand Down Expand Up @@ -259,6 +262,30 @@ export class AccountFacade {
this.store.dispatch(updateCustomerAddress({ address }));
}

// NEWSLETTER
subscribedToNewsletter$ = this.store.pipe(select(getNewsletterSubscriptionStatus));

loadNewsletterSubscription(): Observable<boolean> {
return this.store.pipe(
select(getServerConfigParameter<boolean>('marketing.newsletterSubscriptionEnabled')),
take(1),
switchMap(enabled => {
if (enabled) {
this.store.dispatch(userNewsletterActions.loadUserNewsletterSubscription());
return this.store.pipe(select(getNewsletterSubscriptionStatus));
}
return of(false);
})
);
}

// should only be called when the server-configuration-parameter 'marketing.newsletterSubscriptionEnabled' is true
updateNewsletterSubscription(subscribedToNewsletter: boolean) {
this.store.dispatch(
userNewsletterActions.updateUserNewsletterSubscription({ subscriptionStatus: subscribedToNewsletter })
);
}

// DATA REQUESTS

dataRequestLoading$ = this.store.pipe(select(getDataRequestLoading));
Expand Down
4 changes: 3 additions & 1 deletion src/app/core/models/customer/customer.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ export type CustomerUserType = {
* registration request data type
*/
export type CustomerRegistrationType = {
credentials?: Credentials;
address: Address;
credentials?: Credentials;
subscribedToNewsletter?: boolean;
} & CustomerUserType &
Captcha;

export interface SsoRegistrationType {
companyInfo: { companyName1: string; companyName2?: string; taxationID: string };
address: Address;
userId: string;
subscribedToNewsletter?: boolean;
}
107 changes: 107 additions & 0 deletions src/app/core/services/newsletter/newsletter.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { of, throwError } from 'rxjs';
import { anything, instance, mock, verify, when } from 'ts-mockito';

import { ApiService } from 'ish-core/services/api/api.service';
import { getNewsletterSubscriptionStatus } from 'ish-core/store/customer/user';
import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';

import { NewsletterService } from './newsletter.service';

describe('Newsletter Service', () => {
let newsletterService: NewsletterService;
let apiServiceMock: ApiService;
let store$: MockStore;

let userEmail: string;

beforeEach(() => {
apiServiceMock = mock(ApiService);
TestBed.configureTestingModule({
providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }, provideMockStore()],
});
newsletterService = TestBed.inject(NewsletterService);
store$ = TestBed.inject(MockStore);

userEmail = '[email protected]';

store$.overrideSelector(getNewsletterSubscriptionStatus, false);
});

it("should subscribe user to newsletter when 'updateNewsletterSubscriptionStatus' is called with 'true'", done => {
when(apiServiceMock.post(anything(), anything())).thenReturn(of(true));

const newStatus = true;

newsletterService.updateNewsletterSubscriptionStatus(newStatus, userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.post(`subscriptions`, anything())).once();
expect(subscriptionStatus).toBeTrue();
done();
});
});

it("should unsubscribe user from the newsletter when 'updateNewsletterSubscriptionStatus' is called with 'false'", done => {
when(apiServiceMock.delete(anything())).thenReturn(of(false));
store$.overrideSelector(getNewsletterSubscriptionStatus, true);

const newStatus = false;

newsletterService.updateNewsletterSubscriptionStatus(newStatus, userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.delete(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});
});

it("should not make an API call when calling 'updateNewsletterSubscriptionStatus' and the status hasn't changed", done => {
when(apiServiceMock.delete(anything())).thenReturn(of(false));
store$.overrideSelector(getNewsletterSubscriptionStatus, true);

const newStatus = true;

newsletterService.updateNewsletterSubscriptionStatus(newStatus, userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.delete(`subscriptions/${userEmail}`)).never();
expect(subscriptionStatus).toBeTrue();
done();
});
});

it("should get the users subscription-status when 'getSubscription' is called", done => {
when(apiServiceMock.get(anything())).thenReturn(of({ active: true }));

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeTrue();
done();
});

when(apiServiceMock.get(anything())).thenReturn(of({ active: false }));

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});
});

it('should return false when "getSubscription" is called and a 404-error is thrown', done => {
when(apiServiceMock.get(anything())).thenReturn(
throwError(() => makeHttpError({ message: 'No subscription found', status: 404 }))
);

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});

when(apiServiceMock.get(anything())).thenReturn(of({ active: false }));

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});
});
});
78 changes: 78 additions & 0 deletions src/app/core/services/newsletter/newsletter.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable, catchError, map, of, switchMap, take, throwError } from 'rxjs';

import { ApiService } from 'ish-core/services/api/api.service';
import { getNewsletterSubscriptionStatus } from 'ish-core/store/customer/user';

/**
* The Newsletter Service handles the newsletter related interaction with the 'subscriptions' REST API.
*/
@Injectable({ providedIn: 'root' })
export class NewsletterService {
constructor(private apiService: ApiService, private store: Store) {}

private newsletterSubscriptionStatus$ = this.store.pipe(select(getNewsletterSubscriptionStatus), take(1));

/**
* Gets the current newsletter subscription status of the user.
*
* @param userEmail The user email.
* @returns The current newsletter subscription status.
* Returns 'false' when a 404-error is thrown, which is the APIs response for "no subscription found".
*/
getSubscription(userEmail: string): Observable<boolean> {
return this.apiService.get(`subscriptions/${userEmail}`).pipe(
map((params: { active: boolean }) => params.active),
catchError(error => {
if (error.status === 404) {
return of(false);
}
return throwError(() => error);
})
);
}

/**
* Updates the newsletter subscription status of the user.
* Doesn't make a REST call when newStatus and currentStatus are the same.
*
* @param newStatus The new newsletter subscription status of the user.
* @param userEmail The user e-mail.
* @returns The new newsletter subscription status.
* Returns the current status when newStatus and currentStatus are the same.
*/
updateNewsletterSubscriptionStatus(newStatus: boolean, userEmail: string): Observable<boolean> {
// only make a REST-call when the status has changed
return this.newsletterSubscriptionStatus$.pipe(
switchMap(currentStatus => {
if (currentStatus === newStatus) {
return of(currentStatus);
}

return newStatus ? this.subscribeToNewsletter(userEmail) : this.unsubscribeFromNewsletter(userEmail);
})
);
}

/**
* always returns 'true'
*/
private subscribeToNewsletter(userEmail: string): Observable<boolean> {
const requestBody = {
name: 'Newsletter',
type: 'Subscription',
active: true,
recipient: userEmail,
};

return this.apiService.post(`subscriptions`, requestBody).pipe(map(() => true));
}

/**
* always returns 'false'
*/
private unsubscribeFromNewsletter(userEmail: string): Observable<boolean> {
return this.apiService.delete(`subscriptions/${userEmail}`).pipe(map(() => false));
}
}
2 changes: 2 additions & 0 deletions src/app/core/store/customer/customer-store.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { OrganizationManagementEffects } from './organization-management/organiz
import { RequisitionManagementEffects } from './requisition-management/requisition-management.effects';
import { SsoRegistrationEffects } from './sso-registration/sso-registration.effects';
import { ssoRegistrationReducer } from './sso-registration/sso-registration.reducer';
import { UserNewsletterEffects } from './user/user-newsletter.effects';
import { UserEffects } from './user/user.effects';
import { userReducer } from './user/user.reducer';

Expand Down Expand Up @@ -53,6 +54,7 @@ const customerEffects = [
RequisitionManagementEffects,
SsoRegistrationEffects,
DataRequestsEffects,
UserNewsletterEffects,
];

@Injectable()
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/store/customer/customer-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ConfigurationService } from 'ish-core/services/configuration/configurat
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 { NewsletterService } from 'ish-core/services/newsletter/newsletter.service';
import { OrderService } from 'ish-core/services/order/order.service';
import { PaymentService } from 'ish-core/services/payment/payment.service';
import { PricesService } from 'ish-core/services/prices/prices.service';
Expand Down Expand Up @@ -178,6 +179,7 @@ describe('Customer Store', () => {
{ provide: CookiesService, useFactory: () => instance(mock(CookiesService)) },
{ provide: DataRequestsService, useFactory: () => instance(mock(DataRequestsService)) },
{ provide: FilterService, useFactory: () => instance(mock(FilterService)) },
{ provide: NewsletterService, useFactory: () => instance(mock(NewsletterService)) },
{ provide: OrderService, useFactory: () => instance(mock(OrderService)) },
{ provide: PaymentService, useFactory: () => instance(mock(PaymentService)) },
{ provide: PricesService, useFactory: () => instance(productPriceServiceMock) },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap } from 'rxjs/operators';
import { concatMap, mergeMap } from 'rxjs/operators';
import { v4 as uuid } from 'uuid';

import { FeatureToggleService } from 'ish-core/feature-toggle.module';
import { SsoRegistrationType } from 'ish-core/models/customer/customer.model';
import { UserService } from 'ish-core/services/user/user.service';
import { userNewsletterActions } from 'ish-core/store/customer/user';
import { mapErrorToAction, mapToPayload } from 'ish-core/utils/operators';

import { registerFailure, registerSuccess, setRegistrationInfo } from './sso-registration.actions';
Expand Down Expand Up @@ -35,7 +36,20 @@ export class SsoRegistrationEffects {
},
userId: data.userId,
})
.pipe(map(registerSuccess), mapErrorToAction(registerFailure))
.pipe(
concatMap(createUserResponse => [
registerSuccess,
...(data.subscribedToNewsletter
? [
userNewsletterActions.updateUserNewsletterSubscription({
subscriptionStatus: true,
userEmail: createUserResponse.user.email,
}),
]
: []),
]),
mapErrorToAction(registerFailure)
)
)
)
);
Expand Down
Loading

0 comments on commit 68c0bc5

Please sign in to comment.