diff --git a/packages/scheduler/src/Bootstrap/Container.ts b/packages/scheduler/src/Bootstrap/Container.ts index eee1a3adc..ce04fffa6 100644 --- a/packages/scheduler/src/Bootstrap/Container.ts +++ b/packages/scheduler/src/Bootstrap/Container.ts @@ -36,6 +36,7 @@ import { PredicateVerifiedEventHandler } from '../Domain/Handler/PredicateVerifi import { VerifyPredicates } from '../Domain/UseCase/VerifyPredicates/VerifyPredicates' import { UserRegisteredEventHandler } from '../Domain/Handler/UserRegisteredEventHandler' import { SubscriptionCancelledEventHandler } from '../Domain/Handler/SubscriptionCancelledEventHandler' +import { ExitDiscountAppliedEventHandler } from '../Domain/Handler/ExitDiscountAppliedEventHandler' // eslint-disable-next-line @typescript-eslint/no-var-requires const newrelicFormatter = require('@newrelic/winston-enricher') @@ -124,6 +125,9 @@ export class ContainerConfigLoader { container .bind(TYPES.SubscriptionCancelledEventHandler) .to(SubscriptionCancelledEventHandler) + container + .bind(TYPES.ExitDiscountAppliedEventHandler) + .to(ExitDiscountAppliedEventHandler) // Services container.bind(TYPES.DomainEventFactory).to(DomainEventFactory) @@ -146,6 +150,7 @@ export class ContainerConfigLoader { ['PREDICATE_VERIFIED', container.get(TYPES.PredicateVerifiedEventHandler)], ['USER_REGISTERED', container.get(TYPES.UserRegisteredEventHandler)], ['SUBSCRIPTION_CANCELLED', container.get(TYPES.SubscriptionCancelledEventHandler)], + ['EXIT_DISCOUNT_APPLIED', container.get(TYPES.ExitDiscountAppliedEventHandler)], ]) if (env.get('SQS_QUEUE_URL', true)) { diff --git a/packages/scheduler/src/Bootstrap/Types.ts b/packages/scheduler/src/Bootstrap/Types.ts index f1b38d464..31ed49ba6 100644 --- a/packages/scheduler/src/Bootstrap/Types.ts +++ b/packages/scheduler/src/Bootstrap/Types.ts @@ -24,6 +24,7 @@ const TYPES = { PredicateVerifiedEventHandler: Symbol.for('PredicateVerifiedEventHandler'), UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'), SubscriptionCancelledEventHandler: Symbol.for('SubscriptionCancelledEventHandler'), + ExitDiscountAppliedEventHandler: Symbol.for('ExitDiscountAppliedEventHandler'), // Services DomainEventPublisher: Symbol.for('DomainEventPublisher'), DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'), diff --git a/packages/scheduler/src/Domain/Event/DomainEventFactory.spec.ts b/packages/scheduler/src/Domain/Event/DomainEventFactory.spec.ts index 850470abf..e05c52f3e 100644 --- a/packages/scheduler/src/Domain/Event/DomainEventFactory.spec.ts +++ b/packages/scheduler/src/Domain/Event/DomainEventFactory.spec.ts @@ -65,6 +65,29 @@ describe('DomainEventFactory', () => { }) }) + it('should create a EXIT_DISCOUNT_WITHDRAW_REQUESTED event', () => { + expect( + createFactory().createExitDiscountWithdrawRequestedEvent({ + userEmail: 'test@test.te', + discountCode: 'exit-20', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }, + origin: 'scheduler', + }, + payload: { + userEmail: 'test@test.te', + discountCode: 'exit-20', + }, + type: 'EXIT_DISCOUNT_WITHDRAW_REQUESTED', + }) + }) + it('should create a EMAIL_MESSAGE_REQUESTED event', () => { expect( createFactory().createEmailMessageRequestedEvent({ diff --git a/packages/scheduler/src/Domain/Event/DomainEventFactory.ts b/packages/scheduler/src/Domain/Event/DomainEventFactory.ts index 62571bb11..b499d20fb 100644 --- a/packages/scheduler/src/Domain/Event/DomainEventFactory.ts +++ b/packages/scheduler/src/Domain/Event/DomainEventFactory.ts @@ -4,6 +4,7 @@ import { DiscountWithdrawRequestedEvent, DomainEventService, EmailMessageRequestedEvent, + ExitDiscountWithdrawRequestedEvent, PredicateVerificationRequestedEvent, } from '@standardnotes/domain-events' import { PredicateAuthority } from '@standardnotes/predicates' @@ -51,6 +52,24 @@ export class DomainEventFactory implements DomainEventFactoryInterface { } } + createExitDiscountWithdrawRequestedEvent(dto: { + userEmail: string + discountCode: string + }): ExitDiscountWithdrawRequestedEvent { + return { + type: 'EXIT_DISCOUNT_WITHDRAW_REQUESTED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userEmail, + userIdentifierType: 'email', + }, + origin: DomainEventService.Scheduler, + }, + payload: dto, + } + } + createEmailMessageRequestedEvent(dto: { userEmail: string messageIdentifier: EmailMessageIdentifier diff --git a/packages/scheduler/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/scheduler/src/Domain/Event/DomainEventFactoryInterface.ts index 0bed70b05..d375e8d6c 100644 --- a/packages/scheduler/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/scheduler/src/Domain/Event/DomainEventFactoryInterface.ts @@ -3,6 +3,7 @@ import { DiscountApplyRequestedEvent, DiscountWithdrawRequestedEvent, EmailMessageRequestedEvent, + ExitDiscountWithdrawRequestedEvent, PredicateVerificationRequestedEvent, } from '@standardnotes/domain-events' @@ -18,4 +19,8 @@ export interface DomainEventFactoryInterface { }): EmailMessageRequestedEvent createDiscountApplyRequestedEvent(dto: { userEmail: string; discountCode: string }): DiscountApplyRequestedEvent createDiscountWithdrawRequestedEvent(dto: { userEmail: string; discountCode: string }): DiscountWithdrawRequestedEvent + createExitDiscountWithdrawRequestedEvent(dto: { + userEmail: string + discountCode: string + }): ExitDiscountWithdrawRequestedEvent } diff --git a/packages/scheduler/src/Domain/Handler/ExitDiscountAppliedEventHandler.spec.ts b/packages/scheduler/src/Domain/Handler/ExitDiscountAppliedEventHandler.spec.ts new file mode 100644 index 000000000..9fb85efad --- /dev/null +++ b/packages/scheduler/src/Domain/Handler/ExitDiscountAppliedEventHandler.spec.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata' + +import { ExitDiscountAppliedEvent } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' + +import { JobRepositoryInterface } from '../Job/JobRepositoryInterface' + +import { ExitDiscountAppliedEventHandler } from './ExitDiscountAppliedEventHandler' + +describe('ExitDiscountAppliedEventHandler', () => { + let timer: TimerInterface + let jobRepository: JobRepositoryInterface + + const createHandler = () => new ExitDiscountAppliedEventHandler(timer, jobRepository) + + beforeEach(() => { + timer = {} as jest.Mocked + timer.getUTCDateNHoursAhead = jest.fn().mockReturnValue(new Date(2)) + timer.convertDateToMicroseconds = jest.fn().mockReturnValue(123) + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + + jobRepository = {} as jest.Mocked + jobRepository.save = jest.fn() + }) + + it('should schedule a job to do an exit discount withdrawal', async () => { + await createHandler().handle({ + payload: { userEmail: 'test@test.te', discountRate: 20 }, + } as jest.Mocked) + + expect(jobRepository.save).toHaveBeenNthCalledWith(1, { + createdAt: 1, + name: 'withdraw-subscription-exit-discount', + scheduledAt: 123, + status: 'pending', + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }) + }) +}) diff --git a/packages/scheduler/src/Domain/Handler/ExitDiscountAppliedEventHandler.ts b/packages/scheduler/src/Domain/Handler/ExitDiscountAppliedEventHandler.ts new file mode 100644 index 000000000..6a84b6260 --- /dev/null +++ b/packages/scheduler/src/Domain/Handler/ExitDiscountAppliedEventHandler.ts @@ -0,0 +1,33 @@ +import { DomainEventHandlerInterface, ExitDiscountAppliedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { TimerInterface } from '@standardnotes/time' + +import TYPES from '../../Bootstrap/Types' +import { Job } from '../Job/Job' +import { JobName } from '../Job/JobName' +import { JobRepositoryInterface } from '../Job/JobRepositoryInterface' +import { JobStatus } from '../Job/JobStatus' + +@injectable() +export class ExitDiscountAppliedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.JobRepository) private jobRepository: JobRepositoryInterface, + ) {} + + async handle(event: ExitDiscountAppliedEvent): Promise { + await this.scheduleExitDiscountWithdraw(event) + } + + private async scheduleExitDiscountWithdraw(event: ExitDiscountAppliedEvent): Promise { + const job = new Job() + job.name = JobName.WITHDRAW_SUBSCRIPTION_EXIT_DISCOUNT + job.scheduledAt = this.timer.convertDateToMicroseconds(this.timer.getUTCDateNHoursAhead(24)) + job.createdAt = this.timer.getTimestampInMicroseconds() + job.status = JobStatus.Pending + job.userIdentifier = event.payload.userEmail + job.userIdentifierType = 'email' + + await this.jobRepository.save(job) + } +} diff --git a/packages/scheduler/src/Domain/Job/JobDoneInterpreter.spec.ts b/packages/scheduler/src/Domain/Job/JobDoneInterpreter.spec.ts index 5104c7e33..6fc3d3396 100644 --- a/packages/scheduler/src/Domain/Job/JobDoneInterpreter.spec.ts +++ b/packages/scheduler/src/Domain/Job/JobDoneInterpreter.spec.ts @@ -253,6 +253,35 @@ describe('JobDoneInterpreter', () => { expect(domainEventPublisher.publish).not.toHaveBeenCalled() }) + it('should request exit discount withdraw', async () => { + jobRepository.findOneByUuid = jest.fn().mockReturnValue({ + name: JobName.WITHDRAW_SUBSCRIPTION_EXIT_DISCOUNT, + userIdentifier: 'test@standardnotes.com', + userIdentifierType: 'email', + } as jest.Mocked) + + await createInterpreter().interpret('1-2-3') + + expect(domainEventFactory.createExitDiscountWithdrawRequestedEvent).toHaveBeenCalledWith({ + userEmail: 'test@standardnotes.com', + discountCode: 'exit-20', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should not request exit discount withdraw if email is missing', async () => { + jobRepository.findOneByUuid = jest.fn().mockReturnValue({ + name: JobName.WITHDRAW_SUBSCRIPTION_EXIT_DISCOUNT, + userIdentifier: '2-3-4', + userIdentifierType: 'uuid', + } as jest.Mocked) + + await createInterpreter().interpret('1-2-3') + + expect(domainEventFactory.createExitDiscountWithdrawRequestedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + it('should do nothing if there is no interpretation for a given job', async () => { jobRepository.findOneByUuid = jest.fn().mockReturnValue({ name: 'foobar' as JobName, diff --git a/packages/scheduler/src/Domain/Job/JobDoneInterpreter.ts b/packages/scheduler/src/Domain/Job/JobDoneInterpreter.ts index c27e0fdb8..1870a6225 100644 --- a/packages/scheduler/src/Domain/Job/JobDoneInterpreter.ts +++ b/packages/scheduler/src/Domain/Job/JobDoneInterpreter.ts @@ -65,6 +65,11 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface { await this.requestDiscountWithdraw(job) } return + case JobName.WITHDRAW_SUBSCRIPTION_EXIT_DISCOUNT: + if (job.userIdentifierType === 'email') { + await this.requestExitDiscountWithdraw(job) + } + return default: this.logger.warn(`[${jobUuid}]${job.name}: job is not interpretable.`) @@ -132,6 +137,17 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface { ) } + private async requestExitDiscountWithdraw(job: Job): Promise { + this.logger.debug(`[${job.uuid}]${job.name}: requesting exit discount withdraw.`) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createExitDiscountWithdrawRequestedEvent({ + userEmail: job.userIdentifier, + discountCode: 'exit-20', + }), + ) + } + private async predicatesAreFulfilled(job: Job): Promise { const predicates = await this.predicateRepository.findByJobUuid(job.uuid) diff --git a/packages/scheduler/src/Domain/Job/JobName.ts b/packages/scheduler/src/Domain/Job/JobName.ts index 8cd974121..ba39e3c60 100644 --- a/packages/scheduler/src/Domain/Job/JobName.ts +++ b/packages/scheduler/src/Domain/Job/JobName.ts @@ -5,4 +5,5 @@ export enum JobName { EXIT_INTERVIEW = 'exit-interview', APPLY_SUBSCRIPTION_DISCOUNT = 'apply-subscription-discount', WITHDRAW_SUBSCRIPTION_DISCOUNT = 'withdraw-subscription-discount', + WITHDRAW_SUBSCRIPTION_EXIT_DISCOUNT = 'withdraw-subscription-exit-discount', }