Skip to content
This repository was archived by the owner on Mar 20, 2023. It is now read-only.

Commit 4e9c1b3

Browse files
authored
feat(api): [automation | whenEmailConfirmationWasRequestedThenSendEmailMessage] implement slice (#283) (#398)
1 parent 9e1015a commit 4e9c1b3

20 files changed

+382
-69
lines changed

Diff for: .eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ module.exports = {
203203
'*.expectEventsPublishedLastly',
204204
'*.expectSubscriptionPosition',
205205
'*.expectCommandExecutedLastly',
206+
'*.expectCommandWasNotAppeared',
206207
],
207208
},
208209
],
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import { CommandBus } from '@nestjs/cqrs';
2-
3-
import { commandBusNoFailWithoutHandler, initWriteTestModule } from '@/shared/test-utils';
1+
import { initAutomationTestModule } from '@/shared/test-utils';
42

53
import { WhenEmailConfirmationWasApprovedThenCompleteUserRegistrationAutomationModule } from './when-email-confirmation-was-approved-then-complete-user-registration-automation.module';
64

75
export async function WhenEmailConfirmationWasApprovedThenCompleteUserRegistrationAutomationModuleAutomationTestModule() {
8-
return initWriteTestModule({
9-
modules: [WhenEmailConfirmationWasApprovedThenCompleteUserRegistrationAutomationModule],
10-
configureModule: (app) => app.overrideProvider(CommandBus).useValue(commandBusNoFailWithoutHandler),
11-
});
6+
return initAutomationTestModule([WhenEmailConfirmationWasApprovedThenCompleteUserRegistrationAutomationModule]);
127
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Inject, Injectable, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
2+
import { CommandBus } from '@nestjs/cqrs';
3+
4+
import { UserRegistrationWasStarted } from '@/events/user-registration-was-started.domain-event';
5+
import { ApplicationEvent } from '@/module/application-command-events';
6+
import { SendEmailMessageApplicationCommand, sendEmailMessageCommand } from '@/module/commands/send-email-message';
7+
import { EmailConfirmationWasRequested } from '@/module/events/email-confirmation-was-requested.domain-event';
8+
import { UserId } from '@/shared/domain.types';
9+
import { ApplicationCommandFactory } from '@/write/shared/application/application-command.factory';
10+
import { APPLICATION_SERVICE, ApplicationService } from '@/write/shared/application/application-service';
11+
import { EVENT_REPOSITORY, EventRepository } from '@/write/shared/application/event-repository';
12+
import { EventStreamName } from '@/write/shared/application/event-stream-name.value-object';
13+
import { EventsSubscription } from '@/write/shared/application/events-subscription/events-subscription';
14+
import { EventsSubscriptionsRegistry } from '@/write/shared/application/events-subscription/events-subscriptions-registry';
15+
16+
type AutomationEvent = EmailConfirmationWasRequested | UserRegistrationWasStarted;
17+
18+
const SUBSCRIPTION_NAME = 'WhenEmailConfirmationWasRequestedThenSendEmailMessage';
19+
20+
@Injectable()
21+
export class EmailConfirmationWasRequestedEventHandler implements OnApplicationBootstrap, OnModuleDestroy {
22+
private eventsSubscription: EventsSubscription;
23+
24+
constructor(
25+
private readonly commandBus: CommandBus,
26+
private readonly commandFactory: ApplicationCommandFactory,
27+
private readonly eventsSubscriptionsFactory: EventsSubscriptionsRegistry,
28+
@Inject(APPLICATION_SERVICE)
29+
private readonly applicationService: ApplicationService,
30+
@Inject(EVENT_REPOSITORY)
31+
private readonly eventRepository: EventRepository,
32+
) {}
33+
34+
async onApplicationBootstrap() {
35+
this.eventsSubscription = this.eventsSubscriptionsFactory
36+
.subscription(`${SUBSCRIPTION_NAME}_Automation_v1`)
37+
.onEvent<EmailConfirmationWasRequested>('EmailConfirmationWasRequested', (event) =>
38+
this.onEmailConfirmationWasRequested(event),
39+
)
40+
.onEvent<UserRegistrationWasStarted>('UserRegistrationWasStarted', (event) =>
41+
this.onUserRegistrationWasStarted(event),
42+
)
43+
.build();
44+
await this.eventsSubscription.start();
45+
}
46+
47+
async onModuleDestroy() {
48+
await this.eventsSubscription.stop();
49+
}
50+
51+
async onUserRegistrationWasStarted(event: ApplicationEvent<UserRegistrationWasStarted>) {
52+
await this.publishAutomationEventAndSendEmailIfPossible(event);
53+
}
54+
55+
async onEmailConfirmationWasRequested(event: ApplicationEvent<EmailConfirmationWasRequested>) {
56+
if (event.data.confirmationFor !== 'user-registration') return;
57+
58+
await this.publishAutomationEventAndSendEmailIfPossible(event);
59+
}
60+
61+
private static eventStreamFor(userId: string) {
62+
return EventStreamName.from(SUBSCRIPTION_NAME, userId);
63+
}
64+
65+
private async publishAutomationEventAndSendEmailIfPossible(event: ApplicationEvent<AutomationEvent>) {
66+
const { userId } = event.data;
67+
const eventStream = EmailConfirmationWasRequestedEventHandler.eventStreamFor(userId);
68+
69+
await this.applicationService.execute(eventStream, { ...event.metadata }, () => [event]);
70+
71+
await this.sendEmailIfPossible(userId, event);
72+
}
73+
74+
private async sendEmailIfPossible(userId: UserId, event: ApplicationEvent) {
75+
const eventStream = EmailConfirmationWasRequestedEventHandler.eventStreamFor(userId);
76+
77+
const { pastEvents } = await this.eventRepository.readDomainStream<AutomationEvent>(eventStream);
78+
79+
const { requestData, userEmail } = pastEvents.reduce<{
80+
requestData?: EmailConfirmationWasRequested['data'];
81+
userEmail?: string;
82+
}>((state, e) => {
83+
switch (e.type) {
84+
case 'EmailConfirmationWasRequested':
85+
return { ...state, requestData: e.data };
86+
case 'UserRegistrationWasStarted':
87+
return { ...state, userEmail: e.data.emailAddress };
88+
default:
89+
return state;
90+
}
91+
}, {});
92+
93+
if (!userEmail || !requestData) {
94+
return;
95+
}
96+
97+
const command = this.commandFactory.applicationCommand((idGenerator) => ({
98+
class: SendEmailMessageApplicationCommand,
99+
...sendEmailMessageCommand({
100+
emailMessageId: idGenerator.generate(),
101+
to: userEmail,
102+
subject: 'Confirm your account',
103+
text: `
104+
Click on link below to confirm your account registration:
105+
https://coderscamp.edu.pl/app/confirmation/${requestData.confirmationToken}
106+
`,
107+
html: `
108+
<div>
109+
Click on link below to confirm your account registration:
110+
https://coderscamp.edu.pl/app/confirmation/${requestData.confirmationToken}
111+
</div>
112+
`,
113+
}),
114+
metadata: { correlationId: event.metadata.correlationId, causationId: event.id },
115+
}));
116+
117+
await this.commandBus.execute(command);
118+
}
119+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { SharedModule } from '@/write/shared/shared.module';
4+
5+
import { EmailConfirmationWasRequestedEventHandler } from './email-confirmation-was-requested.event-handler.service';
6+
7+
@Module({
8+
imports: [SharedModule],
9+
providers: [EmailConfirmationWasRequestedEventHandler],
10+
})
11+
export class WhenEmailConfirmationWasRequestedThenSendEmailMessageAutomationModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AsyncReturnType } from 'type-fest';
2+
3+
import { emailConfirmationWasRequestedEvent } from '@/module/events/email-confirmation-was-requested.domain-event';
4+
import { userRegistrationWasStartedEvent } from '@/module/events/user-registration-was-started.domain-event';
5+
import { EventStreamName } from '@/write/shared/application/event-stream-name.value-object';
6+
7+
import { whenEmailConfirmationWasRequestedThenSendEmailMessageAutomationTestModule } from './when-email-confirmation-was-requested-then-send-email-message.test-module';
8+
9+
describe('SendEmailMessage when emailConfirmationWasRequested', () => {
10+
let moduleUnderTest: AsyncReturnType<
11+
typeof whenEmailConfirmationWasRequestedThenSendEmailMessageAutomationTestModule
12+
>;
13+
14+
beforeEach(async () => {
15+
moduleUnderTest = await whenEmailConfirmationWasRequestedThenSendEmailMessageAutomationTestModule();
16+
});
17+
18+
afterEach(async () => {
19+
await moduleUnderTest.close();
20+
});
21+
22+
it("does nothing if confirmation is not for 'user-registration'", async () => {
23+
// Given
24+
const userId = 'ca63d023-4cbd-40ca-9f53-f19dbb19b0ab';
25+
const confirmationFor = 'reset-password';
26+
const confirmationToken = '41c2c1fc8f6cdc15.d5ee8246071726582172f83d569287951a0d727c94dfc35e291fe17abec789c2';
27+
const event = emailConfirmationWasRequestedEvent({ userId, confirmationFor, confirmationToken });
28+
29+
// When
30+
await moduleUnderTest.eventOccurred(
31+
EventStreamName.from('EmailConfirmation', `${userId}_${confirmationFor}`),
32+
event,
33+
);
34+
35+
// then
36+
await moduleUnderTest.expectCommandWasNotAppeared();
37+
});
38+
39+
it('does not create command SendEmailMessage when only UserRegistrationWasStarted event occured', async () => {
40+
// Given
41+
const userId = 'ca63d023-4cbd-40ca-9f53-jkdckshkcj';
42+
const fullName = 'Jan Kowalski';
43+
const emailAddress = '[email protected]';
44+
const hashedPassword = 'StronkPasswort';
45+
46+
const userRegistrationWasStarted = userRegistrationWasStartedEvent({
47+
userId,
48+
fullName,
49+
emailAddress,
50+
hashedPassword,
51+
});
52+
53+
// When
54+
await moduleUnderTest.eventOccurred(
55+
EventStreamName.from('UserRegistration', `${userId}`),
56+
userRegistrationWasStarted,
57+
);
58+
// then
59+
await moduleUnderTest.expectCommandWasNotAppeared();
60+
});
61+
62+
it("creates command SendEmailMessage for confirmation 'user-registration' when registration was started and requested email Confirmation", async () => {
63+
// Given
64+
const userId = 'ca63d023-4cbd-40ca-9f53-jkdckshkcj';
65+
const fullName = 'Jan Kowalski';
66+
const emailAddress = '[email protected]';
67+
const hashedPassword = 'StronkPasswort';
68+
const confirmationFor = 'user-registration';
69+
const confirmationToken = '41c2c1fc8f6cdc15.d5ee8246071726582172f83d569287951a0d727c94dfc35e291fe17abec789c2';
70+
71+
const userRegistrationWasStarted = userRegistrationWasStartedEvent({
72+
userId,
73+
fullName,
74+
emailAddress,
75+
hashedPassword,
76+
});
77+
78+
await moduleUnderTest.eventOccurred(
79+
EventStreamName.from('UserRegistration', `${userId}`),
80+
userRegistrationWasStarted,
81+
);
82+
83+
// When
84+
const emailConfirmationWasRequested = emailConfirmationWasRequestedEvent({
85+
userId,
86+
confirmationFor,
87+
confirmationToken,
88+
});
89+
90+
await moduleUnderTest.eventOccurred(
91+
EventStreamName.from('EmailConfirmation', `${userId}_${confirmationFor}`),
92+
emailConfirmationWasRequested,
93+
);
94+
95+
// then
96+
await moduleUnderTest.expectCommandExecutedLastly({
97+
type: 'SendEmailMessage',
98+
data: {
99+
to: emailAddress,
100+
subject: 'Confirm your account',
101+
},
102+
});
103+
});
104+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { initAutomationTestModule } from '@/shared/test-utils';
2+
3+
import { WhenEmailConfirmationWasRequestedThenSendEmailMessageAutomationModule } from './when-email-confirmation-was-requested-then-send-email-message-automation.module';
4+
5+
export async function whenEmailConfirmationWasRequestedThenSendEmailMessageAutomationTestModule() {
6+
return initAutomationTestModule([WhenEmailConfirmationWasRequestedThenSendEmailMessageAutomationModule]);
7+
}
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
import { CommandBus } from '@nestjs/cqrs';
2-
31
import { WhenUserRegistrationWasStartedThenRequestEmailConfirmationAutomationModule } from '@/automation/when-user-registration-was-started-then-request-email-confirmation/when-user-registration-was-started-then-request-email-confirmation-automation.module';
4-
import { commandBusNoFailWithoutHandler, initWriteTestModule } from '@/shared/test-utils';
2+
import { initAutomationTestModule } from '@/shared/test-utils';
53

64
export async function whenUserRegistrationWasStartedThenRequestEmailConfirmationAutomationTestModule() {
7-
return initWriteTestModule({
8-
modules: [WhenUserRegistrationWasStartedThenRequestEmailConfirmationAutomationModule],
9-
configureModule: (app) => app.overrideProvider(CommandBus).useValue(commandBusNoFailWithoutHandler),
10-
});
5+
return initAutomationTestModule([WhenUserRegistrationWasStartedThenRequestEmailConfirmationAutomationModule]);
116
}

Diff for: packages/api/src/module/shared/commands/send-email-message.application-command.ts

-5
This file was deleted.

Diff for: packages/api/src/module/shared/commands/send-email-message.domain-command.ts

-12
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { EmailMessageId } from '@/shared/domain.types';
2+
3+
import { AbstractApplicationCommand } from '../application-command-events';
4+
5+
export type SendEmailMessage = {
6+
type: 'SendEmailMessage';
7+
data: {
8+
emailMessageId: EmailMessageId;
9+
to: string;
10+
subject: string;
11+
text: string;
12+
html: string;
13+
};
14+
};
15+
16+
export const sendEmailMessageCommand = (data: SendEmailMessage['data']): SendEmailMessage => ({
17+
type: 'SendEmailMessage',
18+
data,
19+
});
20+
21+
export class SendEmailMessageApplicationCommand extends AbstractApplicationCommand<SendEmailMessage> {}

Diff for: packages/api/src/module/write/email-sender/application/send-email-message.command-handler.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Inject } from '@nestjs/common';
22
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
33

4-
import { SendEmailMessageApplicationCommand } from '@/commands/send-email-message.application-command';
4+
import { SendEmailMessageApplicationCommand } from '@/commands/send-email-message';
55
import { env } from '@/shared/env';
66
import { EMAIL_SENDER, EmailSender } from '@/write/email-sender/application/email-sender';
77
import { EmailMessageDomainEvent } from '@/write/email-sender/domain/events';

Diff for: packages/api/src/module/write/email-sender/domain/sendEmailMessage.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SendEmailMessage } from '@/commands/send-email-message.domain-command';
1+
import { SendEmailMessage } from '@/module/commands/send-email-message';
22
import { EmailMessageDomainEvent } from '@/write/email-sender/domain/events';
33
import { sendEmailMessage } from '@/write/email-sender/domain/sendEmailMessage';
44

Diff for: packages/api/src/module/write/email-sender/domain/sendEmailMessage.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SendEmailMessage } from '@/commands/send-email-message.domain-command';
1+
import { SendEmailMessage } from '@/module/commands/send-email-message';
22
import { EmailMessageDomainEvent } from '@/write/email-sender/domain/events';
33

44
export function sendEmailMessage(

Diff for: packages/api/src/module/write/email-sender/email-sending.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AsyncReturnType } from 'type-fest';
22

3-
import { SendEmailMessageApplicationCommand } from '@/commands/send-email-message.application-command';
3+
import { SendEmailMessageApplicationCommand } from '@/commands/send-email-message';
44
import { EmailMessageWasSent } from '@/events/email-message-was-sent.domain-event';
55
import { emailSendingTestModule } from '@/write/email-sender/email-sending.test-module';
66
import { EventStreamName } from '@/write/shared/application/event-stream-name.value-object';

Diff for: packages/api/src/module/write/shared/application/event-repository.ts

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type StorableEvent<
1414

1515
export type ReadAllFilter = { streamCategory?: string; eventTypes?: string[]; fromGlobalPosition?: number };
1616

17+
export type DomainStream<Event extends DomainEvent> = { pastEvents: Event[]; streamVersion: EventStreamVersion };
18+
1719
export interface EventRepository {
1820
read(streamName: EventStreamName): Promise<EventStream>;
1921

@@ -24,4 +26,6 @@ export interface EventRepository {
2426
): Promise<ApplicationEvent[]>;
2527

2628
readAll(filter: Partial<ReadAllFilter>): Promise<ApplicationEvent[]>;
29+
30+
readDomainStream<Event extends DomainEvent>(streamName: EventStreamName): Promise<DomainStream<Event>>;
2731
}

0 commit comments

Comments
 (0)