Преглед на файлове

feat(settings): add unsubscribe token for muting emails

Karol Sójko преди 2 години
родител
ревизия
3953dbc6b4

+ 1 - 0
packages/domain-core/src/Domain/Setting/SettingName.ts

@@ -22,6 +22,7 @@ export class SettingName extends ValueObject<SettingNameProps> {
     LogSessionUserAgent: 'LOG_SESSION_USER_AGENT',
     FileUploadBytesLimit: 'FILE_UPLOAD_BYTES_LIMIT',
     FileUploadBytesUsed: 'FILE_UPLOAD_BYTES_USED',
+    EmailUnsubscribeToken: 'EMAIL_UNSUBSCRIBE_TOKEN',
   }
 
   get value(): string {

+ 12 - 1
packages/settings/src/Bootstrap/Container.ts

@@ -7,6 +7,7 @@ import {
   DomainEventMessageHandlerInterface,
   DomainEventSubscriberFactoryInterface,
 } from '@standardnotes/domain-events'
+import { MapperInterface } from '@standardnotes/domain-core'
 import { TokenDecoderInterface, CrossServiceTokenData, TokenDecoder } from '@standardnotes/security'
 import {
   RedisDomainEventSubscriberFactory,
@@ -26,6 +27,8 @@ import { TypeORMSetting } from '../Infra/TypeORM/TypeORMSetting'
 import { SettingRepositoryInterface } from '../Domain/Setting/SettingRepositoryInterface'
 import { MySQLSettingRepository } from '../Infra/MySQL/MySQLSettingRepository'
 import { SettingsController } from '../Controller/SettingsController'
+import { SettingPersistenceMapper } from '../Mapping/SettingPersistenceMapper'
+import { Setting } from '../Domain/Setting/Setting'
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -78,6 +81,9 @@ export class ContainerConfigLoader {
     }
 
     // Map
+    container
+      .bind<MapperInterface<Setting, TypeORMSetting>>(TYPES.SettingPersistenceMapper)
+      .toConstantValue(new SettingPersistenceMapper())
 
     // ORM
     container
@@ -95,7 +101,12 @@ export class ContainerConfigLoader {
     // Repositories
     container
       .bind<SettingRepositoryInterface>(TYPES.SettingRepository)
-      .toConstantValue(new MySQLSettingRepository(container.get(TYPES.ORMSettingRepository)))
+      .toConstantValue(
+        new MySQLSettingRepository(
+          container.get(TYPES.ORMSettingRepository),
+          container.get(TYPES.SettingPersistenceMapper),
+        ),
+      )
 
     // use cases
     container

+ 1 - 0
packages/settings/src/Bootstrap/Types.ts

@@ -4,6 +4,7 @@ const TYPES = {
   Redis: Symbol.for('Redis'),
   SQS: Symbol.for('SQS'),
   // Map
+  SettingPersistenceMapper: Symbol.for('SettingPersistenceMapper'),
   // ORM
   ORMSettingRepository: Symbol.for('ORMSettingRepository'),
   // Repositories

+ 1 - 1
packages/settings/src/Controller/SettingsController.ts

@@ -8,7 +8,7 @@ export class SettingsController {
 
   async muteAllEmails(params: MuteAllEmailsRequestParams): Promise<HttpResponse> {
     const result = await this.doMuteAllEmails.execute({
-      userUuid: params.userUuid,
+      unsubscribeToken: params.unsubscribeToken,
     })
 
     if (result.isFailed()) {

+ 4 - 1
packages/settings/src/Domain/Setting/SettingRepositoryInterface.ts

@@ -1,5 +1,8 @@
-import { Uuid } from '@standardnotes/domain-core'
+import { SettingName, Uuid } from '@standardnotes/domain-core'
+
+import { Setting } from './Setting'
 
 export interface SettingRepositoryInterface {
+  findOneByNameAndValue(name: SettingName, value: string): Promise<Setting | null>
   setValueOnMultipleSettings(settingNames: string[], userUuid: Uuid, value: string | null): Promise<void>
 }

+ 18 - 3
packages/settings/src/Domain/UseCase/MuteAllEmails/MuteAllEmails.spec.ts

@@ -1,3 +1,6 @@
+import { Uuid } from '@standardnotes/domain-core'
+
+import { Setting } from '../../Setting/Setting'
 import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
 import { MuteAllEmails } from './MuteAllEmails'
 
@@ -9,17 +12,29 @@ describe('MuteAllEmails', () => {
   beforeEach(() => {
     settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
     settingRepository.setValueOnMultipleSettings = jest.fn()
+    settingRepository.findOneByNameAndValue = jest.fn().mockReturnValue({
+      props: { userUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue() },
+    } as jest.Mocked<Setting>)
   })
 
   it('should mute all email settings for a given user', async () => {
-    const result = await createUseCase().execute({ userUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d' })
+    const result = await createUseCase().execute({ unsubscribeToken: 'foobar' })
 
     expect(result.isFailed()).toBeFalsy()
     expect(settingRepository.setValueOnMultipleSettings).toHaveBeenCalled()
   })
 
-  it('should not mute all email settings for an invalid user uuid', async () => {
-    const result = await createUseCase().execute({ userUuid: '1-2-3' })
+  it('should not mute all email settings if user was not found', async () => {
+    settingRepository.findOneByNameAndValue = jest.fn().mockReturnValue(null)
+    const result = await createUseCase().execute({ unsubscribeToken: 'foobar' })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(settingRepository.setValueOnMultipleSettings).not.toHaveBeenCalled()
+  })
+
+  it('should not mute all email settings if unsubscribe token is not provided', async () => {
+    settingRepository.findOneByNameAndValue = jest.fn().mockReturnValue(null)
+    const result = await createUseCase().execute({ unsubscribeToken: null as unknown as string })
 
     expect(result.isFailed()).toBeTruthy()
     expect(settingRepository.setValueOnMultipleSettings).not.toHaveBeenCalled()

+ 16 - 6
packages/settings/src/Domain/UseCase/MuteAllEmails/MuteAllEmails.ts

@@ -1,4 +1,4 @@
-import { Result, SettingName, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { Result, SettingName, UseCaseInterface } from '@standardnotes/domain-core'
 
 import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
 import { MuteAllEmailsDTO } from './MuteAllEmailsDTO'
@@ -7,13 +7,23 @@ export class MuteAllEmails implements UseCaseInterface<string> {
   constructor(private settingRepository: SettingRepositoryInterface) {}
 
   async execute(dto: MuteAllEmailsDTO): Promise<Result<string>> {
-    const userUuidOrError = Uuid.create(dto.userUuid)
-    if (userUuidOrError.isFailed()) {
-      return Result.fail(`Could not mute user emails: ${userUuidOrError.getError()}`)
+    if (!dto.unsubscribeToken) {
+      return Result.fail('No unsubscribe token provider')
     }
-    const userUuid = userUuidOrError.getValue()
 
-    await this.settingRepository.setValueOnMultipleSettings([SettingName.NAMES.MuteMarketingEmails], userUuid, 'muted')
+    const unsubscribeTokenSetting = await this.settingRepository.findOneByNameAndValue(
+      SettingName.create(SettingName.NAMES.EmailUnsubscribeToken).getValue(),
+      dto.unsubscribeToken,
+    )
+    if (unsubscribeTokenSetting === null) {
+      return Result.fail(`Could not find user with given unsubscribe token: ${dto.unsubscribeToken}`)
+    }
+
+    await this.settingRepository.setValueOnMultipleSettings(
+      [SettingName.NAMES.MuteMarketingEmails],
+      unsubscribeTokenSetting.props.userUuid,
+      'muted',
+    )
 
     return Result.ok('Muted all emails.')
   }

+ 1 - 1
packages/settings/src/Domain/UseCase/MuteAllEmails/MuteAllEmailsDTO.ts

@@ -1,3 +1,3 @@
 export interface MuteAllEmailsDTO {
-  userUuid: string
+  unsubscribeToken: string
 }

+ 1 - 1
packages/settings/src/Infra/Http/MuteAllEmailsRequestParams.ts

@@ -1,3 +1,3 @@
 export interface MuteAllEmailsRequestParams {
-  userUuid: string
+  unsubscribeToken: string
 }

+ 2 - 2
packages/settings/src/Infra/InversifyExpress/InversifyExpressSettingsController.ts

@@ -11,10 +11,10 @@ export class InversifyExpressSettingsController extends BaseHttpController {
     super()
   }
 
-  @httpPost('/mute-emails')
+  @httpPost('/mute-emails/:unsubscribeToken')
   public async muteAllEmails(request: Request): Promise<results.JsonResult> {
     const result = await this.settingsController.muteAllEmails({
-      userUuid: request.params.userUuid,
+      unsubscribeToken: request.params.unsubscribeToken,
     })
 
     return this.json(result.data, result.status)

+ 20 - 2
packages/settings/src/Infra/MySQL/MySQLSettingRepository.ts

@@ -1,11 +1,29 @@
-import { Uuid } from '@standardnotes/domain-core'
+import { MapperInterface, SettingName, Uuid } from '@standardnotes/domain-core'
 import { Repository } from 'typeorm'
+import { Setting } from '../../Domain/Setting/Setting'
 
 import { SettingRepositoryInterface } from '../../Domain/Setting/SettingRepositoryInterface'
 import { TypeORMSetting } from '../TypeORM/TypeORMSetting'
 
 export class MySQLSettingRepository implements SettingRepositoryInterface {
-  constructor(private ormRepository: Repository<TypeORMSetting>) {}
+  constructor(
+    private ormRepository: Repository<TypeORMSetting>,
+    private settingMapper: MapperInterface<Setting, TypeORMSetting>,
+  ) {}
+
+  async findOneByNameAndValue(name: SettingName, value: string): Promise<Setting | null> {
+    const typeormSetting = await this.ormRepository
+      .createQueryBuilder()
+      .where('name = :name', { name: name.value })
+      .andWhere('value = :value', { value })
+      .getOne()
+
+    if (typeormSetting === null) {
+      return null
+    }
+
+    return this.settingMapper.toDomain(typeormSetting)
+  }
 
   async setValueOnMultipleSettings(settingNames: string[], userUuid: Uuid, value: string | null): Promise<void> {
     await this.ormRepository

+ 72 - 0
packages/settings/src/Mapping/SettingPersistenceMapper.ts

@@ -0,0 +1,72 @@
+import {
+  EncryptionVersion,
+  MapperInterface,
+  SettingName,
+  Timestamps,
+  UniqueEntityId,
+  Uuid,
+} from '@standardnotes/domain-core'
+
+import { Setting } from '../Domain/Setting/Setting'
+import { TypeORMSetting } from '../Infra/TypeORM/TypeORMSetting'
+
+export class SettingPersistenceMapper implements MapperInterface<Setting, TypeORMSetting> {
+  toDomain(projection: TypeORMSetting): Setting {
+    const settingNameOrError = SettingName.create(projection.name)
+    if (settingNameOrError.isFailed()) {
+      throw new Error(`Could not create setting projection: ${settingNameOrError.getError()}`)
+    }
+    const name = settingNameOrError.getValue()
+
+    const serverEncryptionVersionOrError = EncryptionVersion.create(projection.serverEncryptionVersion)
+    if (serverEncryptionVersionOrError.isFailed()) {
+      throw new Error(`Could not create setting projection: ${serverEncryptionVersionOrError.getError()}`)
+    }
+    const serverEncryptionVersion = serverEncryptionVersionOrError.getValue()
+
+    const userUuidOrError = Uuid.create(projection.userUuid)
+    if (userUuidOrError.isFailed()) {
+      throw new Error(`Could not create setting projection: ${userUuidOrError.getError()}`)
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const timestampsOrError = Timestamps.create(projection.createdAt, projection.updatedAt)
+    if (timestampsOrError.isFailed()) {
+      throw new Error(`Could not create setting projection: ${timestampsOrError.getError()}`)
+    }
+    const timestamps = timestampsOrError.getValue()
+
+    const settingOrError = Setting.create(
+      {
+        name,
+        sensitive: projection.sensitive,
+        serverEncryptionVersion,
+        timestamps,
+        userUuid,
+        value: projection.value,
+      },
+      new UniqueEntityId(projection.uuid),
+    )
+    if (settingOrError.isFailed()) {
+      throw new Error(`Could not create setting projection: ${settingOrError.getError()}`)
+    }
+    const setting = settingOrError.getValue()
+
+    return setting
+  }
+
+  toProjection(domain: Setting): TypeORMSetting {
+    const typeOrmSetting = new TypeORMSetting()
+
+    typeOrmSetting.name = domain.props.name.value
+    typeOrmSetting.sensitive = domain.props.sensitive
+    typeOrmSetting.serverEncryptionVersion = domain.props.serverEncryptionVersion.value
+    typeOrmSetting.userUuid = domain.props.userUuid.value
+    typeOrmSetting.uuid = domain.id.toString()
+    typeOrmSetting.value = domain.props.value
+    typeOrmSetting.createdAt = domain.props.timestamps.createdAt
+    typeOrmSetting.updatedAt = domain.props.timestamps.updatedAt
+
+    return typeOrmSetting
+  }
+}