From 74ebc050eb802af8d1744185f3b9b1b7b8832603 Mon Sep 17 00:00:00 2001 From: Anna Hughes Date: Mon, 17 Jun 2024 15:56:51 +0100 Subject: [PATCH 1/7] fix: fix STORYBLOK_PUBLIC_TOKEN undefined (#481) --- src/utils/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a6839960..7500c56f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -118,7 +118,8 @@ export const crispWebsiteId = getEnv(process.env.CRISP_WEBSITE_ID, 'CRISP_WEBSIT export const slackWebhookUrl = getEnv(process.env.SLACK_WEBHOOK_URL, 'SLACK_WEBHOOK_URL'); -export const storyblokToken = getEnv(process.env.STORYBLOK_PUBLIC_TOKEN, 'STORYBLOK_PUBLIC_TOKEN'); +export const storyblokToken = + getEnv(process.env.STORYBLOK_PUBLIC_TOKEN, 'STORYBLOK_PUBLIC_TOKEN') || ''; export const storyblokWebhookSecret = getEnv( process.env.STORYBLOK_WEBHOOK_SECRET, From c86ca04b091d5be30f07078046bb3158dd30d5b2 Mon Sep 17 00:00:00 2001 From: Anna Hughes Date: Mon, 17 Jun 2024 16:04:06 +0100 Subject: [PATCH 2/7] fix: undefined STORYBLOK_WEBHOOK_SECRET (#483) --- src/utils/constants.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 7500c56f..3d50b4b3 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -118,13 +118,10 @@ export const crispWebsiteId = getEnv(process.env.CRISP_WEBSITE_ID, 'CRISP_WEBSIT export const slackWebhookUrl = getEnv(process.env.SLACK_WEBHOOK_URL, 'SLACK_WEBHOOK_URL'); -export const storyblokToken = - getEnv(process.env.STORYBLOK_PUBLIC_TOKEN, 'STORYBLOK_PUBLIC_TOKEN') || ''; +export const storyblokToken = getEnv(process.env.STORYBLOK_PUBLIC_TOKEN, 'STORYBLOK_PUBLIC_TOKEN'); -export const storyblokWebhookSecret = getEnv( - process.env.STORYBLOK_WEBHOOK_SECRET, - 'STORYBLOK_WEBHOOK_SECRET', -); +export const storyblokWebhookSecret = + getEnv(process.env.STORYBLOK_WEBHOOK_SECRET, 'STORYBLOK_WEBHOOK_SECRET') || ''; export const simplybookCredentials = getEnv( process.env.SIMPLYBOOK_CREDENTIALS, From bb63d7c3a418532c14baccc39219c35f742eedaf Mon Sep 17 00:00:00 2001 From: Anna Hughes Date: Mon, 17 Jun 2024 18:52:36 +0100 Subject: [PATCH 3/7] fix: mailchimp therapy update (#484) --- src/webhooks/webhooks.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts index 4090e0a0..84bd74e8 100644 --- a/src/webhooks/webhooks.service.ts +++ b/src/webhooks/webhooks.service.ts @@ -243,7 +243,8 @@ export class WebhooksService { await this.partnerAccessRepository.save(partnerAccess); const therapySession = await this.therapySessionRepository.save(serializedTherapySession); - updateServiceUserProfilesTherapy([...partnerAccesses, partnerAccess], user.email); + partnerAccess.therapySession.push(therapySession); + updateServiceUserProfilesTherapy(partnerAccesses, user.email); return therapySession; } catch (err) { From e14efe63b56c50aceedc04392e0fb332ae3820aa Mon Sep 17 00:00:00 2001 From: Haydn Appleby Date: Tue, 18 Jun 2024 10:09:28 +0100 Subject: [PATCH 4/7] feat: adds & updates service user profile related tests for error catching (#478) * adds generic test for external api call error suppression under the service users profiles spec * adds dedicated mailchimp api call fails test for partner access service - requires some extra mocking and minor reorganisation * adds dedicated crisp and mailchimp api call fail tests for create and update user * make sure to await the calls on service user profile methods (probably was not an issue in production but under test the runs could complete before mocked methods were called) * remove the awaits --- .../partner-access.service.spec.ts | 27 ++++++++-- src/user/user.service.spec.ts | 51 +++++++++++++++++++ src/user/user.service.ts | 2 +- src/utils/serviceUserProfiles.spec.ts | 47 +++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/partner-access/partner-access.service.spec.ts b/src/partner-access/partner-access.service.spec.ts index bfd5dd88..110741cb 100644 --- a/src/partner-access/partner-access.service.spec.ts +++ b/src/partner-access/partner-access.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { sub } from 'date-fns'; import * as crispApi from 'src/api/crisp/crisp-api'; +import * as mailchimpApi from 'src/api/mailchimp/mailchimp-api'; import { PartnerEntity } from 'src/entities/partner.entity'; import { GetUserDto } from 'src/user/dtos/get-user.dto'; import * as profileData from 'src/utils/serviceUserProfiles'; @@ -46,11 +47,12 @@ jest.mock('src/api/crisp/crisp-api', () => ({ getCrispProfileData: jest.fn(), updateCrispProfileBase: jest.fn(), updateCrispProfile: jest.fn(), - updateServiceUserProfilesPartnerAccess: jest.fn(), })); -jest.mock('src/utils/serviceUserProfiles', () => ({ - updateServiceUserProfilesPartnerAccess: jest.fn(), +jest.mock('src/api/mailchimp/mailchimp-api', () => ({ + createMailchimpMergeField: jest.fn(), + createMailchimpProfile: jest.fn(), + updateMailchimpProfile: jest.fn(), })); describe('PartnerAccessService', () => { @@ -143,6 +145,8 @@ describe('PartnerAccessService', () => { }); // Mocks that the accesscode already exists jest.spyOn(repo, 'findOne').mockResolvedValueOnce(mockPartnerAccessEntity); + // Observer on the service user profiles method + jest.spyOn(profileData, 'updateServiceUserProfilesPartnerAccess'); const partnerAccess = await service.assignPartnerAccess(mockUserEntity, '123456'); @@ -175,6 +179,23 @@ describe('PartnerAccessService', () => { }); }); + it('should assign partner access even if mailchimp profile api fails', async () => { + // Mocks that the accesscode already exists + jest.spyOn(repo, 'findOne').mockResolvedValueOnce(mockPartnerAccessEntity); + + jest.spyOn(mailchimpApi, 'updateMailchimpProfile').mockImplementationOnce(async () => { + throw new Error('Test throw'); + }); + + const partnerAccess = await service.assignPartnerAccess(mockUserEntity, '123456'); + + expect(partnerAccess).toEqual({ + ...mockPartnerAccessEntity, + userId: mockUserEntity.id, + activatedAt: partnerAccess.activatedAt, + }); + }); + it('should return an error when partner access code has already been used by another user account', async () => { jest.spyOn(repo, 'findOne').mockResolvedValueOnce({ ...mockPartnerAccessEntity, diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index c189cba0..37f7c335 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -4,6 +4,7 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { createCrispProfile, updateCrispProfile } from 'src/api/crisp/crisp-api'; +import { createMailchimpProfile, updateMailchimpProfile } from 'src/api/mailchimp/mailchimp-api'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; @@ -226,6 +227,30 @@ describe('UserService', () => { { ...partnerAccessData, therapySessions: [mockTherapySessionDto] }, ]); }); + + it('should not fail on crisp api call errors', async () => { + const mocked = jest.mocked(createCrispProfile); + mocked.mockRejectedValue(new Error('Crisp API call failed')); + + const user = await service.createUser(createUserDto); + + expect(mocked).toHaveBeenCalled(); + expect(user.user.email).toBe('user@email.com'); + + mocked.mockReset(); + }); + + it('should not fail on mailchimp api call errors', async () => { + const mocked = jest.mocked(createMailchimpProfile); + mocked.mockRejectedValue(new Error('Mailchimp API call failed')); + + const user = await service.createUser(createUserDto); + + expect(mocked).toHaveBeenCalled(); + expect(user.user.email).toBe('user@email.com'); + + mocked.mockReset(); + }); }); describe('getUser', () => { @@ -264,6 +289,32 @@ describe('UserService', () => { expect(repoSaveSpy).toHaveBeenCalledWith({ ...mockUserEntity, ...updateUserDto }); expect(repoSaveSpy).toHaveBeenCalled(); }); + + it('should not fail on crisp api call errors', async () => { + const mocked = jest.mocked(updateCrispProfile); + mocked.mockRejectedValue(new Error('Crisp API call failed')); + + const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); + + expect(mocked).toHaveBeenCalled(); + expect(user.name).toBe('new name'); + expect(user.email).toBe('user@email.com'); + + mocked.mockReset(); + }); + + it('should not fail on mailchimp api call errors', async () => { + const mocked = jest.mocked(updateMailchimpProfile); + mocked.mockRejectedValue(new Error('Mailchimp API call failed')); + + const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); + + expect(mocked).toHaveBeenCalled(); + expect(user.name).toBe('new name'); + expect(user.email).toBe('user@email.com'); + + mocked.mockReset(); + }); }); describe('deleteUserById', () => { diff --git a/src/user/user.service.ts b/src/user/user.service.ts index be589ad9..189fe6a2 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -91,7 +91,7 @@ export class UserService { this.logger.log(`Create user: created public user in db. User: ${email}`); } - createServiceUserProfiles(user, partner, partnerAccess); + await createServiceUserProfiles(user, partner, partnerAccess); const userDto = formatUserObject({ ...user, diff --git a/src/utils/serviceUserProfiles.spec.ts b/src/utils/serviceUserProfiles.spec.ts index dbd6257e..7fa3b976 100644 --- a/src/utils/serviceUserProfiles.spec.ts +++ b/src/utils/serviceUserProfiles.spec.ts @@ -129,7 +129,15 @@ describe('Service user profiles', () => { }, }); }); + + it('should not propagate external api call errors', async () => { + const mocked = jest.mocked(createCrispProfile); + mocked.mockRejectedValue(new Error('Crisp API call failed')); + await expect(createServiceUserProfiles(mockUserEntity)).resolves.not.toThrow(); + mocked.mockReset(); + }); }); + describe('updateServiceUserProfilesUser', () => { it('should update crisp and mailchimp profile user data', async () => { await updateServiceUserProfilesUser(mockUserEntity, false, mockUserEntity.email); @@ -209,6 +217,15 @@ describe('Service user profiles', () => { mockUserEntity.email, ); }); + + it('should not propagate external api call errors', async () => { + const mocked = jest.mocked(updateMailchimpProfile); + mocked.mockRejectedValue(new Error('Mailchimp API call failed')); + await expect( + updateServiceUserProfilesUser(mockUserEntity, false, mockUserEntity.email), + ).resolves.not.toThrow(); + mocked.mockReset(); + }); }); describe('updateServiceUserProfilesPartnerAccess', () => { @@ -286,6 +303,15 @@ describe('Service user profiles', () => { mockUserEntity.email, ); }); + + it('should not propagate external api call errors', async () => { + const mocked = jest.mocked(updateCrispProfile); + mocked.mockRejectedValue(new Error('Crisp API call failed')); + await expect( + updateServiceUserProfilesPartnerAccess([mockPartnerAccessEntity], mockUserEntity.email), + ).resolves.not.toThrow(); + mocked.mockReset(); + }); }); describe('updateServiceUserProfilesTherapy', () => { @@ -441,6 +467,18 @@ describe('Service user profiles', () => { mockUserEntity.email, ); }); + + it('should not propagate external api call errors', async () => { + const mocked = jest.mocked(updateMailchimpProfile); + mocked.mockRejectedValue(new Error('Mailchimp API call failed')); + await expect( + updateServiceUserProfilesTherapy( + [mockPartnerAccessEntity, mockAltPartnerAccessEntity], + mockUserEntity.email, + ), + ).resolves.not.toThrow(); + mocked.mockReset(); + }); }); describe('updateServiceUserProfilesCourse', () => { @@ -465,6 +503,15 @@ describe('Service user profiles', () => { mockUserEntity.email, ); }); + + it('should not propagate external api call errors', async () => { + const mocked = jest.mocked(updateCrispProfile); + mocked.mockRejectedValue(new Error('Crisp API call failed')); + await expect( + updateServiceUserProfilesCourse(mockCourseUserEntity, mockUserEntity.email), + ).resolves.not.toThrow(); + mocked.mockReset(); + }); }); describe('createMailchimpCourseMergeField', () => { From b9dc86416ad92aee6e7bc8b114e21daa5467d368 Mon Sep 17 00:00:00 2001 From: Anna Hughes Date: Tue, 18 Jun 2024 13:55:44 +0100 Subject: [PATCH 5/7] feat: add lastActiveAt field to user (#475) --- src/api/crisp/crisp-api.interfaces.ts | 1 + src/api/mailchimp/mailchimp-api.interfaces.ts | 1 + src/entities/user.entity.ts | 3 ++ src/migrations/1718300621138-bloom-backend.ts | 16 +++++++++ .../partner-admin-auth.guard.spec.ts | 1 + src/typeorm.config.ts | 2 ++ src/user/dtos/update-user.dto.ts | 7 +++- src/user/user.controller.ts | 6 ++-- src/user/user.interface.ts | 1 + src/user/user.service.spec.ts | 34 +++++++++---------- src/user/user.service.ts | 10 +++--- src/utils/serialize.ts | 2 ++ src/utils/serviceUserProfiles.spec.ts | 24 ++++++++++--- src/utils/serviceUserProfiles.ts | 6 ++-- test/utils/mockData.ts | 3 +- 15 files changed, 84 insertions(+), 33 deletions(-) create mode 100644 src/migrations/1718300621138-bloom-backend.ts diff --git a/src/api/crisp/crisp-api.interfaces.ts b/src/api/crisp/crisp-api.interfaces.ts index 5c17d0ff..95f93516 100644 --- a/src/api/crisp/crisp-api.interfaces.ts +++ b/src/api/crisp/crisp-api.interfaces.ts @@ -1,5 +1,6 @@ export interface CrispProfileCustomFields { signed_up_at?: string; + last_active_at?: string; language?: string; marketing_permission?: boolean; service_emails_permission?: boolean; diff --git a/src/api/mailchimp/mailchimp-api.interfaces.ts b/src/api/mailchimp/mailchimp-api.interfaces.ts index 6f5f19fc..ce730b72 100644 --- a/src/api/mailchimp/mailchimp-api.interfaces.ts +++ b/src/api/mailchimp/mailchimp-api.interfaces.ts @@ -15,6 +15,7 @@ export enum MAILCHIMP_MERGE_FIELD_TYPES { export interface ListMemberCustomFields { NAME?: string; SIGNUPD?: string; + LACTIVED?: string; PARTNERS?: string; FEATTHER?: string; FEATCHAT?: string; diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 00c057fb..b318c62b 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -36,6 +36,9 @@ export class UserEntity extends BaseBloomEntity { @Column({ type: Boolean, default: true }) isActive: boolean; + @Column({ type: 'timestamptz', nullable: true }) + lastActiveAt: Date; // set each time user record is fetched + @OneToMany(() => PartnerAccessEntity, (partnerAccess) => partnerAccess.user, { cascade: true }) partnerAccess: PartnerAccessEntity[]; diff --git a/src/migrations/1718300621138-bloom-backend.ts b/src/migrations/1718300621138-bloom-backend.ts new file mode 100644 index 00000000..10b748ce --- /dev/null +++ b/src/migrations/1718300621138-bloom-backend.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BloomBackend1718300621138 implements MigrationInterface { + name = 'BloomBackend1718300621138' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`); + await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`); + await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" date`); + } + +} diff --git a/src/partner-admin/partner-admin-auth.guard.spec.ts b/src/partner-admin/partner-admin-auth.guard.spec.ts index 589f9da1..107f94ea 100644 --- a/src/partner-admin/partner-admin-auth.guard.spec.ts +++ b/src/partner-admin/partner-admin-auth.guard.spec.ts @@ -22,6 +22,7 @@ const userEntity: UserEntity = { partnerAccess: [], partnerAdmin: { id: 'partnerAdminId', active: true, partner: {} } as PartnerAdminEntity, isActive: true, + lastActiveAt: new Date(), courseUser: [], signUpLanguage: 'en', subscriptionUser: [], diff --git a/src/typeorm.config.ts b/src/typeorm.config.ts index ef394d94..e40ddab0 100644 --- a/src/typeorm.config.ts +++ b/src/typeorm.config.ts @@ -44,6 +44,7 @@ import { bloomBackend1696994943309 } from './migrations/1696994943309-bloom-back import { bloomBackend1697818259254 } from './migrations/1697818259254-bloom-backend'; import { bloomBackend1698136145516 } from './migrations/1698136145516-bloom-backend'; import { bloomBackend1706174260018 } from './migrations/1706174260018-bloom-backend'; +import { BloomBackend1718300621138 } from './migrations/1718300621138-bloom-backend'; config(); const configService = new ConfigService(); @@ -108,6 +109,7 @@ export const dataSourceOptions = { bloomBackend1697818259254, bloomBackend1698136145516, bloomBackend1706174260018, + BloomBackend1718300621138, ], subscribers: [], ssl: isProduction, diff --git a/src/user/dtos/update-user.dto.ts b/src/user/dtos/update-user.dto.ts index 7d9f6b7e..2e018a35 100644 --- a/src/user/dtos/update-user.dto.ts +++ b/src/user/dtos/update-user.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator'; export class UpdateUserDto { @IsString() @@ -21,4 +21,9 @@ export class UpdateUserDto { @IsOptional() @ApiProperty({ type: String }) signUpLanguage: string; + + @IsDate() + @IsOptional() + @ApiProperty({ type: 'date' }) + lastActiveAt: Date; } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 2ea45444..737dd70b 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -44,13 +44,15 @@ export class UserController { @Get('/me') @UseGuards(FirebaseAuthGuard) async getUserByFirebaseId(@Req() req: Request): Promise { - return req['user']; + const user = req['user']; + this.userService.updateUser({ lastActiveAt: new Date() }, user); + return user; } /** * This POST endpoint deviates from REST patterns. * Please use `getUserByFirebaseId` above which is a GET endpoint. - * Do not delete this until frontend usage is migrated. + * Safe to delete function below from July 2024 - allowing for caches to clear */ @ApiBearerAuth('access-token') @ApiOperation({ diff --git a/src/user/user.interface.ts b/src/user/user.interface.ts index 9f58f69a..e80e28de 100644 --- a/src/user/user.interface.ts +++ b/src/user/user.interface.ts @@ -6,6 +6,7 @@ export interface IUser { name: string; email: string; isActive: boolean; + lastActiveAt: Date | string; crispTokenId: string; isSuperAdmin: boolean; signUpLanguage: string; diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 37f7c335..50845c4e 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -42,17 +42,7 @@ const createUserDto: CreateUserDto = { signUpLanguage: 'en', }; -const createUserRepositoryDto = { - email: 'user@email.com', - password: 'password', - name: 'name', - contactPermission: false, - serviceEmailsPermission: true, - signUpLanguage: 'en', - firebaseUid: mockUserRecord.uid, -}; - -const updateUserDto: UpdateUserDto = { +const updateUserDto: Partial = { name: 'new name', contactPermission: true, serviceEmailsPermission: false, @@ -123,7 +113,12 @@ describe('UserService', () => { const repoSaveSpy = jest.spyOn(repo, 'save'); const user = await service.createUser(createUserDto); - expect(repoSaveSpy).toHaveBeenCalledWith(createUserRepositoryDto); + expect(repoSaveSpy).toHaveBeenCalledWith({ + ...createUserDto, + firebaseUid: mockUserRecord.uid, + lastActiveAt: user.user.lastActiveAt, + }); + expect(user.user.email).toBe('user@email.com'); expect(user.partnerAdmin).toBeNull(); expect(user.partnerAccesses).toBeNull(); @@ -135,6 +130,7 @@ describe('UserService', () => { segments: ['public'], }); expect(updateCrispProfile).toHaveBeenCalled(); + expect(createMailchimpProfile).toHaveBeenCalled(); }); it('when supplied with user dto and partner access code, it should return a new partner user', async () => { @@ -169,6 +165,7 @@ describe('UserService', () => { expect(updateCrispProfile).toHaveBeenCalledWith( { signed_up_at: user.user.createdAt, + last_active_at: (user.user.lastActiveAt as Date).toISOString(), marketing_permission: true, service_emails_permission: true, partners: 'bumble', @@ -179,6 +176,7 @@ describe('UserService', () => { }, 'user@email.com', ); + expect(createMailchimpProfile).toHaveBeenCalled(); }); it('when supplied with user dto and partner access that has already been used, it should return an error', async () => { @@ -228,7 +226,7 @@ describe('UserService', () => { ]); }); - it('should not fail on crisp api call errors', async () => { + it('should not fail create on crisp api call errors', async () => { const mocked = jest.mocked(createCrispProfile); mocked.mockRejectedValue(new Error('Crisp API call failed')); @@ -240,7 +238,7 @@ describe('UserService', () => { mocked.mockReset(); }); - it('should not fail on mailchimp api call errors', async () => { + it('should not fail create on mailchimp api call errors', async () => { const mocked = jest.mocked(createMailchimpProfile); mocked.mockRejectedValue(new Error('Mailchimp API call failed')); @@ -290,12 +288,12 @@ describe('UserService', () => { expect(repoSaveSpy).toHaveBeenCalled(); }); - it('should not fail on crisp api call errors', async () => { + it('should not fail update on crisp api call errors', async () => { const mocked = jest.mocked(updateCrispProfile); mocked.mockRejectedValue(new Error('Crisp API call failed')); const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); - + await new Promise(process.nextTick); // wait for async funcs to resolve expect(mocked).toHaveBeenCalled(); expect(user.name).toBe('new name'); expect(user.email).toBe('user@email.com'); @@ -303,12 +301,12 @@ describe('UserService', () => { mocked.mockReset(); }); - it('should not fail on mailchimp api call errors', async () => { + it('should not fail update on mailchimp api call errors', async () => { const mocked = jest.mocked(updateMailchimpProfile); mocked.mockRejectedValue(new Error('Mailchimp API call failed')); const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); - + await new Promise(process.nextTick); // wait for async funcs to resolve expect(mocked).toHaveBeenCalled(); expect(user.name).toBe('new name'); expect(user.email).toBe('user@email.com'); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 189fe6a2..369862b8 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -67,6 +67,7 @@ export class UserService { const user = await this.userRepository.save({ ...createUserDto, + lastActiveAt: new Date(), firebaseUid: firebaseUser.uid, }); @@ -190,7 +191,7 @@ export class UserService { return await this.deleteUser(user); } - public async updateUser(updateUserDto: UpdateUserDto, { user: { id } }: GetUserDto) { + public async updateUser(updateUserDto: Partial, { user: { id } }: GetUserDto) { const user = await this.userRepository.findOneBy({ id }); if (!user) { @@ -203,9 +204,10 @@ export class UserService { }; const updatedUser = await this.userRepository.save(newUserData); - const isNameOrLanguageUpdated = - user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name; - updateServiceUserProfilesUser(user, isNameOrLanguageUpdated, user.email); + const isCrispBaseUpdateRequired = + (user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name) || + user.lastActiveAt !== updateUserDto.lastActiveAt; + updateServiceUserProfilesUser(user, isCrispBaseUpdateRequired, user.email); return updatedUser; } diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts index fed5488c..cef352f6 100644 --- a/src/utils/serialize.ts +++ b/src/utils/serialize.ts @@ -87,6 +87,7 @@ export const formatUserObject = (userObject: UserEntity): GetUserDto => { email: userObject.email, firebaseUid: userObject.firebaseUid, isActive: userObject.isActive, + lastActiveAt: userObject.lastActiveAt, crispTokenId: userObject.crispTokenId, isSuperAdmin: userObject.isSuperAdmin, signUpLanguage: userObject.signUpLanguage, @@ -122,6 +123,7 @@ export const formatGetUsersObject = (userObject: UserEntity): GetUserDto => { email: userObject.email, firebaseUid: userObject.firebaseUid, isActive: userObject.isActive, + lastActiveAt: userObject.lastActiveAt, crispTokenId: userObject.crispTokenId, isSuperAdmin: userObject.isSuperAdmin, signUpLanguage: userObject.signUpLanguage, diff --git a/src/utils/serviceUserProfiles.spec.ts b/src/utils/serviceUserProfiles.spec.ts index 7fa3b976..8032ae1b 100644 --- a/src/utils/serviceUserProfiles.spec.ts +++ b/src/utils/serviceUserProfiles.spec.ts @@ -45,11 +45,15 @@ describe('Service user profiles', () => { segments: ['public'], }); + const createdAt = mockUserEntity.createdAt.toISOString(); + const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); + expect(updateCrispProfile).toHaveBeenCalledWith( { marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, - signed_up_at: mockUserEntity.createdAt.toISOString(), + signed_up_at: createdAt, + last_active_at: lastActiveAt, feature_live_chat: true, feature_therapy: false, partners: '', @@ -71,7 +75,8 @@ describe('Service user profiles', () => { }, ], merge_fields: { - SIGNUPD: mockUserEntity.createdAt.toISOString(), + SIGNUPD: createdAt, + LACTIVED: lastActiveAt, NAME: mockUserEntity.name, FEATCHAT: 'true', FEATTHER: 'false', @@ -86,6 +91,8 @@ describe('Service user profiles', () => { await createServiceUserProfiles(mockUserEntity, mockPartnerEntity, mockPartnerAccessEntity); const partnerName = mockPartnerEntity.name.toLowerCase(); + const createdAt = mockUserEntity.createdAt.toISOString(); + const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); expect(createCrispProfile).toHaveBeenCalledWith({ email: mockUserEntity.email, @@ -95,10 +102,11 @@ describe('Service user profiles', () => { expect(updateCrispProfile).toHaveBeenCalledWith( { - signed_up_at: mockUserEntity.createdAt.toISOString(), + signed_up_at: createdAt, marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, partners: partnerName, + last_active_at: lastActiveAt, feature_live_chat: mockPartnerAccessEntity.featureLiveChat, feature_therapy: mockPartnerAccessEntity.featureTherapy, therapy_sessions_remaining: mockPartnerAccessEntity.therapySessionsRemaining, @@ -120,6 +128,7 @@ describe('Service user profiles', () => { ], merge_fields: { SIGNUPD: mockUserEntity.createdAt.toISOString(), + LACTIVED: lastActiveAt, NAME: mockUserEntity.name, PARTNERS: partnerName, FEATCHAT: String(mockPartnerAccessEntity.featureLiveChat), @@ -142,10 +151,13 @@ describe('Service user profiles', () => { it('should update crisp and mailchimp profile user data', async () => { await updateServiceUserProfilesUser(mockUserEntity, false, mockUserEntity.email); + const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); + expect(updateCrispProfile).toHaveBeenCalledWith( { marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, + last_active_at: lastActiveAt, }, mockUserEntity.email, ); @@ -161,7 +173,7 @@ describe('Service user profiles', () => { enabled: mockUserEntity.contactPermission, }, ], - merge_fields: { NAME: mockUserEntity.name }, + merge_fields: { NAME: mockUserEntity.name, LACTIVED: lastActiveAt }, }, mockUserEntity.email, ); @@ -173,6 +185,7 @@ describe('Service user profiles', () => { contactPermission: false, serviceEmailsPermission: false, }; + const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); await updateServiceUserProfilesUser(mockUser, false, mockUser.email); @@ -180,6 +193,7 @@ describe('Service user profiles', () => { { marketing_permission: false, service_emails_permission: false, + last_active_at: lastActiveAt, }, mockUser.email, ); @@ -195,7 +209,7 @@ describe('Service user profiles', () => { enabled: false, }, ], - merge_fields: { NAME: mockUser.name }, + merge_fields: { NAME: mockUser.name, LACTIVED: lastActiveAt }, }, mockUser.email, ); diff --git a/src/utils/serviceUserProfiles.ts b/src/utils/serviceUserProfiles.ts index 93de52ea..f6a3fa73 100644 --- a/src/utils/serviceUserProfiles.ts +++ b/src/utils/serviceUserProfiles.ts @@ -214,11 +214,13 @@ const serializeCrispPartnerSegments = (partners: PartnerEntity[]) => { }; const serializeUserData = (user: UserEntity) => { - const { name, signUpLanguage, contactPermission, serviceEmailsPermission } = user; + const { name, signUpLanguage, contactPermission, serviceEmailsPermission, lastActiveAt } = user; + const lastActiveAtString = lastActiveAt?.toISOString() || ''; const crispSchema = { marketing_permission: contactPermission, service_emails_permission: serviceEmailsPermission, + last_active_at: lastActiveAtString, // Name and language handled on base level profile for crisp }; @@ -232,7 +234,7 @@ const serializeUserData = (user: UserEntity) => { }, ], language: signUpLanguage || 'en', - merge_fields: { NAME: name }, + merge_fields: { NAME: name, LACTIVED: lastActiveAtString }, } as ListMemberPartial; return { crispSchema, mailchimpSchema }; diff --git a/test/utils/mockData.ts b/test/utils/mockData.ts index 258e57bc..4fa4ae5e 100644 --- a/test/utils/mockData.ts +++ b/test/utils/mockData.ts @@ -128,11 +128,12 @@ export const mockUserEntity: UserEntity = { id: 'userId1', isSuperAdmin: false, isActive: true, + lastActiveAt: new Date(), createdAt: new Date(), + updatedAt: new Date(), partnerAccess: [], partnerAdmin: null, courseUser: [], - updatedAt: new Date(), crispTokenId: '123', firebaseUid: '123', contactPermission: true, From b9d81c885ea5ac3fbf184b3272c34d690d5b3544 Mon Sep 17 00:00:00 2001 From: Anna Hughes Date: Tue, 18 Jun 2024 15:37:08 +0100 Subject: [PATCH 6/7] fix: lastActiveAt migration (#485) --- src/migrations/1718300621138-bloom-backend.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/migrations/1718300621138-bloom-backend.ts b/src/migrations/1718300621138-bloom-backend.ts index 10b748ce..5d410bfc 100644 --- a/src/migrations/1718300621138-bloom-backend.ts +++ b/src/migrations/1718300621138-bloom-backend.ts @@ -1,16 +1,13 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class BloomBackend1718300621138 implements MigrationInterface { - name = 'BloomBackend1718300621138' + name = 'BloomBackend1718300621138'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`); - await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" TIMESTAMP WITH TIME ZONE`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`); - await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" date`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" TIMESTAMP WITH TIME ZONE`); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`); + } } From 0881cbadf235383abb0153ed6f3944cf5a07c53a Mon Sep 17 00:00:00 2001 From: Anna Hughes Date: Tue, 18 Jun 2024 18:03:19 +0100 Subject: [PATCH 7/7] feat: emailRemindersFrequency field (#486) --- README.md | 2 ++ src/api/crisp/crisp-api.interfaces.ts | 3 ++ src/api/mailchimp/mailchimp-api.interfaces.ts | 3 ++ src/api/mailchimp/mailchimp-api.ts | 1 - src/entities/user.entity.ts | 4 +++ src/logger/logger.ts | 1 + src/migrations/1718728423454-bloom-backend.ts | 14 +++++++++ .../partner-admin-auth.guard.spec.ts | 2 ++ src/typeorm.config.ts | 2 ++ src/user/dtos/create-user.dto.ts | 6 ++++ src/user/dtos/update-user.dto.ts | 6 ++++ src/user/user.interface.ts | 3 ++ src/user/user.service.spec.ts | 4 ++- src/user/user.service.ts | 6 ++-- src/utils/constants.ts | 31 ++++++++++++------- src/utils/serialize.ts | 2 ++ src/utils/serviceUserProfiles.spec.ts | 24 ++++++++++++-- src/utils/serviceUserProfiles.ts | 16 ++++++++-- test/utils/mockData.ts | 7 ++++- 19 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 src/migrations/1718728423454-bloom-backend.ts diff --git a/README.md b/README.md index 785461f2..ef07a688 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ To generate a new migration yarn migration:generate ``` +Add the new migration import into [typeorm.config.ts](./src/typeorm.config.ts) + To run (apply) migrations ```bash diff --git a/src/api/crisp/crisp-api.interfaces.ts b/src/api/crisp/crisp-api.interfaces.ts index 95f93516..9b4f967f 100644 --- a/src/api/crisp/crisp-api.interfaces.ts +++ b/src/api/crisp/crisp-api.interfaces.ts @@ -1,6 +1,9 @@ +import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; + export interface CrispProfileCustomFields { signed_up_at?: string; last_active_at?: string; + email_reminders_frequency?: EMAIL_REMINDERS_FREQUENCY; language?: string; marketing_permission?: boolean; service_emails_permission?: boolean; diff --git a/src/api/mailchimp/mailchimp-api.interfaces.ts b/src/api/mailchimp/mailchimp-api.interfaces.ts index ce730b72..76b6e03d 100644 --- a/src/api/mailchimp/mailchimp-api.interfaces.ts +++ b/src/api/mailchimp/mailchimp-api.interfaces.ts @@ -1,3 +1,5 @@ +import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; + export enum MAILCHIMP_MERGE_FIELD_TYPES { TEXT = 'text', NUMBER = 'number', @@ -16,6 +18,7 @@ export interface ListMemberCustomFields { NAME?: string; SIGNUPD?: string; LACTIVED?: string; + REMINDFREQ?: EMAIL_REMINDERS_FREQUENCY; PARTNERS?: string; FEATTHER?: string; FEATCHAT?: string; diff --git a/src/api/mailchimp/mailchimp-api.ts b/src/api/mailchimp/mailchimp-api.ts index 7696fcb6..ee9cc14b 100644 --- a/src/api/mailchimp/mailchimp-api.ts +++ b/src/api/mailchimp/mailchimp-api.ts @@ -59,7 +59,6 @@ export const batchCreateMailchimpProfiles = async (users: UserEntity[]) => { console.log('Mailchimp batch response:', batchResponse); }, 120000); } catch (error) { - console.log(error); throw new Error(`Batch create mailchimp profiles API call failed: ${error}`); } }; diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index b318c62b..e88ad6f6 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, Generated, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; import { PartnerAccessEntity } from '../entities/partner-access.entity'; import { PartnerAdminEntity } from '../entities/partner-admin.entity'; +import { EMAIL_REMINDERS_FREQUENCY } from '../utils/constants'; import { BaseBloomEntity } from './base.entity'; import { CourseUserEntity } from './course-user.entity'; import { EventLogEntity } from './event-log.entity'; @@ -30,6 +31,9 @@ export class UserEntity extends BaseBloomEntity { @Column({ default: true }) serviceEmailsPermission: boolean; // service emails consent - mapped to mailchimp status field + @Column({ default: EMAIL_REMINDERS_FREQUENCY.NEVER }) + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY; + @Column({ type: Boolean, default: false }) isSuperAdmin: boolean; diff --git a/src/logger/logger.ts b/src/logger/logger.ts index beed3845..4dfa4edd 100644 --- a/src/logger/logger.ts +++ b/src/logger/logger.ts @@ -37,6 +37,7 @@ export class Logger extends ConsoleLogger { accessToken: rollbarToken, captureUncaught: true, captureUnhandledRejections: true, + captureIp: 'anonymize', ignoredMessages: [...Object.values(FIREBASE_ERRORS)], }); } diff --git a/src/migrations/1718728423454-bloom-backend.ts b/src/migrations/1718728423454-bloom-backend.ts new file mode 100644 index 00000000..2610b547 --- /dev/null +++ b/src/migrations/1718728423454-bloom-backend.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BloomBackend1718728423454 implements MigrationInterface { + name = 'BloomBackend1718728423454' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "emailRemindersFrequency" character varying NOT NULL DEFAULT 'NEVER'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "emailRemindersFrequency"`); + } + +} diff --git a/src/partner-admin/partner-admin-auth.guard.spec.ts b/src/partner-admin/partner-admin-auth.guard.spec.ts index 107f94ea..e3df4955 100644 --- a/src/partner-admin/partner-admin-auth.guard.spec.ts +++ b/src/partner-admin/partner-admin-auth.guard.spec.ts @@ -4,6 +4,7 @@ import { DecodedIdToken } from 'firebase-admin/lib/auth/token-verifier'; import { AuthService } from 'src/auth/auth.service'; import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { EMAIL_REMINDERS_FREQUENCY } from 'src/utils/constants'; import { Repository } from 'typeorm'; import { createQueryBuilderMock } from '../../test/utils/mockUtils'; import { PartnerAdminAuthGuard } from './partner-admin-auth.guard'; @@ -17,6 +18,7 @@ const userEntity: UserEntity = { name: 'name', contactPermission: false, serviceEmailsPermission: true, + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, isSuperAdmin: false, crispTokenId: '123', partnerAccess: [], diff --git a/src/typeorm.config.ts b/src/typeorm.config.ts index e40ddab0..91d1fadd 100644 --- a/src/typeorm.config.ts +++ b/src/typeorm.config.ts @@ -45,6 +45,7 @@ import { bloomBackend1697818259254 } from './migrations/1697818259254-bloom-back import { bloomBackend1698136145516 } from './migrations/1698136145516-bloom-backend'; import { bloomBackend1706174260018 } from './migrations/1706174260018-bloom-backend'; import { BloomBackend1718300621138 } from './migrations/1718300621138-bloom-backend'; +import { BloomBackend1718728423454 } from './migrations/1718728423454-bloom-backend'; config(); const configService = new ConfigService(); @@ -110,6 +111,7 @@ export const dataSourceOptions = { bloomBackend1698136145516, bloomBackend1706174260018, BloomBackend1718300621138, + BloomBackend1718728423454, ], subscribers: [], ssl: isProduction, diff --git a/src/user/dtos/create-user.dto.ts b/src/user/dtos/create-user.dto.ts index 195fe073..dcedfd81 100644 --- a/src/user/dtos/create-user.dto.ts +++ b/src/user/dtos/create-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDefined, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; export class CreateUserDto { @IsString() @@ -40,6 +41,11 @@ export class CreateUserDto { @ApiProperty({ type: Boolean }) serviceEmailsPermission: boolean; + @IsOptional() + @IsString() + @ApiProperty({ type: String }) + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY; + @IsOptional() @IsString() @ApiProperty({ type: String }) diff --git a/src/user/dtos/update-user.dto.ts b/src/user/dtos/update-user.dto.ts index 2e018a35..6a946a29 100644 --- a/src/user/dtos/update-user.dto.ts +++ b/src/user/dtos/update-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator'; +import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; export class UpdateUserDto { @IsString() @@ -17,6 +18,11 @@ export class UpdateUserDto { @ApiProperty({ type: Boolean }) serviceEmailsPermission: boolean; + @IsOptional() + @IsString() + @ApiProperty({ type: String }) + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY; + @IsString() @IsOptional() @ApiProperty({ type: String }) diff --git a/src/user/user.interface.ts b/src/user/user.interface.ts index e80e28de..f9b7287b 100644 --- a/src/user/user.interface.ts +++ b/src/user/user.interface.ts @@ -1,3 +1,5 @@ +import { EMAIL_REMINDERS_FREQUENCY } from '../utils/constants'; + export interface IUser { id: string; createdAt: Date | string; @@ -10,4 +12,5 @@ export interface IUser { crispTokenId: string; isSuperAdmin: boolean; signUpLanguage: string; + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY; } diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 50845c4e..d17bc6e5 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -9,7 +9,7 @@ import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; -import { PartnerAccessCodeStatusEnum } from 'src/utils/constants'; +import { EMAIL_REMINDERS_FREQUENCY, PartnerAccessCodeStatusEnum } from 'src/utils/constants'; import { mockIFirebaseUser, mockPartnerAccessEntity, @@ -39,6 +39,7 @@ const createUserDto: CreateUserDto = { name: 'name', contactPermission: false, serviceEmailsPermission: true, + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, signUpLanguage: 'en', }; @@ -168,6 +169,7 @@ describe('UserService', () => { last_active_at: (user.user.lastActiveAt as Date).toISOString(), marketing_permission: true, service_emails_permission: true, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, partners: 'bumble', feature_live_chat: true, feature_therapy: true, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 369862b8..1fd4a9d9 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -197,7 +197,6 @@ export class UserService { if (!user) { throw new HttpException('USER NOT FOUND', HttpStatus.NOT_FOUND); } - const newUserData: UserEntity = { ...user, ...updateUserDto, @@ -205,8 +204,8 @@ export class UserService { const updatedUser = await this.userRepository.save(newUserData); const isCrispBaseUpdateRequired = - (user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name) || - user.lastActiveAt !== updateUserDto.lastActiveAt; + user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name; + updateServiceUserProfilesUser(user, isCrispBaseUpdateRequired, user.email); return updatedUser; @@ -332,7 +331,6 @@ export class UserService { }); const usersWithCourseUsers = users.filter((user) => user.courseUser.length > 0); - console.log(usersWithCourseUsers); await batchCreateMailchimpProfiles(usersWithCourseUsers); this.logger.log( `Created batch mailchimp profiles for ${usersWithCourseUsers.length} users, created before ${filterStartDate}`, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3d50b4b3..644cfff5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,12 +1,31 @@ import dotenv from 'dotenv'; dotenv.config(); +export enum ENVIRONMENTS { + DEVELOPMENT = 'development', + STAGING = 'staging', + PRODUCTION = 'production', + TEST = 'test', +} + export enum SIGNUP_TYPE { PUBLIC_USER = 'PUBLIC_USER', PARTNER_USER_WITH_CODE = 'PARTNER_USER_WITH_CODE', PARTNER_USER_WITHOUT_CODE = 'PARTNER_USER_WITHOUT_CODE', } +export enum LANGUAGE_DEFAULT { + EN = 'en', + ES = 'es', +} + +export enum EMAIL_REMINDERS_FREQUENCY { + TWO_WEEKS = 'TWO_WEEKS', + ONE_MONTH = 'ONE_MONTH', + TWO_MONTHS = 'TWO_MONTHS', + NEVER = 'NEVER', +} + export enum FEATURES { AUTOMATIC_ACCESS_CODE = 'AUTOMATIC_ACCESS_CODE', } @@ -30,11 +49,6 @@ export enum STORYBLOK_STORY_STATUS_ENUM { DELETED = 'deleted', } -export enum LANGUAGE_DEFAULT { - EN = 'en', - ES = 'es', -} - export enum PartnerAccessCodeStatusEnum { VALID = 'VALID', INVALID_CODE = 'INVALID_CODE', @@ -53,13 +67,6 @@ export enum COMMUNICATION_SERVICE { MAILCHIMP = 'MAILCHIMP', } -export enum ENVIRONMENTS { - DEVELOPMENT = 'development', - STAGING = 'staging', - PRODUCTION = 'production', - TEST = 'test', -} - const getEnv = (env: string, envName: string): string => { try { if (!env) throw `Unable to get environment variable ${envName}`; diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts index cef352f6..46971b33 100644 --- a/src/utils/serialize.ts +++ b/src/utils/serialize.ts @@ -91,6 +91,7 @@ export const formatUserObject = (userObject: UserEntity): GetUserDto => { crispTokenId: userObject.crispTokenId, isSuperAdmin: userObject.isSuperAdmin, signUpLanguage: userObject.signUpLanguage, + emailRemindersFrequency: userObject.emailRemindersFrequency, }, partnerAccesses: userObject.partnerAccess ? formatPartnerAccessObjects(userObject.partnerAccess) @@ -127,6 +128,7 @@ export const formatGetUsersObject = (userObject: UserEntity): GetUserDto => { crispTokenId: userObject.crispTokenId, isSuperAdmin: userObject.isSuperAdmin, signUpLanguage: userObject.signUpLanguage, + emailRemindersFrequency: userObject.emailRemindersFrequency, }, ...(userObject.partnerAccess ? { diff --git a/src/utils/serviceUserProfiles.spec.ts b/src/utils/serviceUserProfiles.spec.ts index 8032ae1b..503d4499 100644 --- a/src/utils/serviceUserProfiles.spec.ts +++ b/src/utils/serviceUserProfiles.spec.ts @@ -16,7 +16,11 @@ import { mockPartnerEntity, mockUserEntity, } from 'test/utils/mockData'; -import { SIMPLYBOOK_ACTION_ENUM, mailchimpMarketingPermissionId } from './constants'; +import { + EMAIL_REMINDERS_FREQUENCY, + SIMPLYBOOK_ACTION_ENUM, + mailchimpMarketingPermissionId, +} from './constants'; import { createMailchimpCourseMergeField, createServiceUserProfiles, @@ -52,6 +56,7 @@ describe('Service user profiles', () => { { marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, signed_up_at: createdAt, last_active_at: lastActiveAt, feature_live_chat: true, @@ -83,6 +88,7 @@ describe('Service user profiles', () => { PARTNERS: '', THERREMAIN: 0, THERREDEEM: 0, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, }, }); }); @@ -105,6 +111,7 @@ describe('Service user profiles', () => { signed_up_at: createdAt, marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, partners: partnerName, last_active_at: lastActiveAt, feature_live_chat: mockPartnerAccessEntity.featureLiveChat, @@ -135,6 +142,7 @@ describe('Service user profiles', () => { FEATTHER: String(mockPartnerAccessEntity.featureTherapy), THERREMAIN: mockPartnerAccessEntity.therapySessionsRemaining, THERREDEEM: mockPartnerAccessEntity.therapySessionsRedeemed, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, }, }); }); @@ -157,6 +165,7 @@ describe('Service user profiles', () => { { marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, last_active_at: lastActiveAt, }, mockUserEntity.email, @@ -173,7 +182,11 @@ describe('Service user profiles', () => { enabled: mockUserEntity.contactPermission, }, ], - merge_fields: { NAME: mockUserEntity.name, LACTIVED: lastActiveAt }, + merge_fields: { + NAME: mockUserEntity.name, + LACTIVED: lastActiveAt, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + }, }, mockUserEntity.email, ); @@ -194,6 +207,7 @@ describe('Service user profiles', () => { marketing_permission: false, service_emails_permission: false, last_active_at: lastActiveAt, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, }, mockUser.email, ); @@ -209,7 +223,11 @@ describe('Service user profiles', () => { enabled: false, }, ], - merge_fields: { NAME: mockUser.name, LACTIVED: lastActiveAt }, + merge_fields: { + NAME: mockUser.name, + LACTIVED: lastActiveAt, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + }, }, mockUser.email, ); diff --git a/src/utils/serviceUserProfiles.ts b/src/utils/serviceUserProfiles.ts index f6a3fa73..805847dc 100644 --- a/src/utils/serviceUserProfiles.ts +++ b/src/utils/serviceUserProfiles.ts @@ -214,13 +214,21 @@ const serializeCrispPartnerSegments = (partners: PartnerEntity[]) => { }; const serializeUserData = (user: UserEntity) => { - const { name, signUpLanguage, contactPermission, serviceEmailsPermission, lastActiveAt } = user; + const { + name, + signUpLanguage, + contactPermission, + serviceEmailsPermission, + lastActiveAt, + emailRemindersFrequency, + } = user; const lastActiveAtString = lastActiveAt?.toISOString() || ''; const crispSchema = { marketing_permission: contactPermission, service_emails_permission: serviceEmailsPermission, last_active_at: lastActiveAtString, + email_reminders_frequency: emailRemindersFrequency, // Name and language handled on base level profile for crisp }; @@ -234,7 +242,11 @@ const serializeUserData = (user: UserEntity) => { }, ], language: signUpLanguage || 'en', - merge_fields: { NAME: name, LACTIVED: lastActiveAtString }, + merge_fields: { + NAME: name, + LACTIVED: lastActiveAtString, + REMINDFREQ: emailRemindersFrequency, + }, } as ListMemberPartial; return { crispSchema, mailchimpSchema }; diff --git a/test/utils/mockData.ts b/test/utils/mockData.ts index 4fa4ae5e..a4e5177b 100644 --- a/test/utils/mockData.ts +++ b/test/utils/mockData.ts @@ -18,7 +18,11 @@ import { UserEntity } from 'src/entities/user.entity'; import { EVENT_NAME } from 'src/event-logger/event-logger.interface'; import { IFirebaseUser } from 'src/firebase/firebase-user.interface'; import { ZapierSimplybookBodyDto } from 'src/partner-access/dtos/zapier-body.dto'; -import { SIMPLYBOOK_ACTION_ENUM, STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; +import { + EMAIL_REMINDERS_FREQUENCY, + SIMPLYBOOK_ACTION_ENUM, + STORYBLOK_STORY_STATUS_ENUM, +} from 'src/utils/constants'; import { ISbResult } from 'storyblok-js-client'; export const mockSessionStoryblokResult = { @@ -138,6 +142,7 @@ export const mockUserEntity: UserEntity = { firebaseUid: '123', contactPermission: true, serviceEmailsPermission: true, + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, email: 'user@email.com', name: 'name', signUpLanguage: 'en',