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 5c17d0ff..9b4f967f 100644 --- a/src/api/crisp/crisp-api.interfaces.ts +++ b/src/api/crisp/crisp-api.interfaces.ts @@ -1,5 +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 6f5f19fc..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', @@ -15,6 +17,8 @@ export enum MAILCHIMP_MERGE_FIELD_TYPES { 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 00c057fb..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,12 +31,18 @@ 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; @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/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/1718300621138-bloom-backend.ts b/src/migrations/1718300621138-bloom-backend.ts new file mode 100644 index 00000000..5d410bfc --- /dev/null +++ b/src/migrations/1718300621138-bloom-backend.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BloomBackend1718300621138 implements MigrationInterface { + name = 'BloomBackend1718300621138'; + + 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"`); + } +} 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-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/partner-admin/partner-admin-auth.guard.spec.ts b/src/partner-admin/partner-admin-auth.guard.spec.ts index 589f9da1..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,11 +18,13 @@ const userEntity: UserEntity = { name: 'name', contactPermission: false, serviceEmailsPermission: true, + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, isSuperAdmin: false, crispTokenId: '123', 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..91d1fadd 100644 --- a/src/typeorm.config.ts +++ b/src/typeorm.config.ts @@ -44,6 +44,8 @@ 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'; +import { BloomBackend1718728423454 } from './migrations/1718728423454-bloom-backend'; config(); const configService = new ConfigService(); @@ -108,6 +110,8 @@ export const dataSourceOptions = { bloomBackend1697818259254, 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 7d9f6b7e..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, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator'; +import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; export class UpdateUserDto { @IsString() @@ -17,8 +18,18 @@ export class UpdateUserDto { @ApiProperty({ type: Boolean }) serviceEmailsPermission: boolean; + @IsOptional() + @IsString() + @ApiProperty({ type: String }) + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY; + @IsString() @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..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; @@ -6,7 +8,9 @@ export interface IUser { name: string; email: string; isActive: boolean; + lastActiveAt: Date | string; 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 c189cba0..d17bc6e5 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -4,11 +4,12 @@ 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'; 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, @@ -38,20 +39,11 @@ const createUserDto: CreateUserDto = { name: 'name', contactPermission: false, serviceEmailsPermission: true, + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, 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, @@ -122,7 +114,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(); @@ -134,6 +131,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 () => { @@ -168,8 +166,10 @@ 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, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, partners: 'bumble', feature_live_chat: true, feature_therapy: true, @@ -178,6 +178,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 () => { @@ -226,6 +227,30 @@ describe('UserService', () => { { ...partnerAccessData, therapySessions: [mockTherapySessionDto] }, ]); }); + + it('should not fail create 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 create 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 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'); + + mocked.mockReset(); + }); + + 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'); + + mocked.mockReset(); + }); }); describe('deleteUserById', () => { diff --git a/src/user/user.service.ts b/src/user/user.service.ts index be589ad9..1fd4a9d9 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, }); @@ -91,7 +92,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, @@ -190,22 +191,22 @@ 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) { throw new HttpException('USER NOT FOUND', HttpStatus.NOT_FOUND); } - const newUserData: UserEntity = { ...user, ...updateUserDto, }; const updatedUser = await this.userRepository.save(newUserData); - const isNameOrLanguageUpdated = + const isCrispBaseUpdateRequired = user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name; - updateServiceUserProfilesUser(user, isNameOrLanguageUpdated, user.email); + + updateServiceUserProfilesUser(user, isCrispBaseUpdateRequired, user.email); return updatedUser; } @@ -330,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 a6839960..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}`; @@ -120,10 +127,8 @@ export const slackWebhookUrl = getEnv(process.env.SLACK_WEBHOOK_URL, 'SLACK_WEBH 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, diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts index fed5488c..46971b33 100644 --- a/src/utils/serialize.ts +++ b/src/utils/serialize.ts @@ -87,9 +87,11 @@ 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, + emailRemindersFrequency: userObject.emailRemindersFrequency, }, partnerAccesses: userObject.partnerAccess ? formatPartnerAccessObjects(userObject.partnerAccess) @@ -122,9 +124,11 @@ 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, + emailRemindersFrequency: userObject.emailRemindersFrequency, }, ...(userObject.partnerAccess ? { diff --git a/src/utils/serviceUserProfiles.spec.ts b/src/utils/serviceUserProfiles.spec.ts index dbd6257e..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, @@ -45,11 +49,16 @@ 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(), + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + signed_up_at: createdAt, + last_active_at: lastActiveAt, feature_live_chat: true, feature_therapy: false, partners: '', @@ -71,13 +80,15 @@ describe('Service user profiles', () => { }, ], merge_fields: { - SIGNUPD: mockUserEntity.createdAt.toISOString(), + SIGNUPD: createdAt, + LACTIVED: lastActiveAt, NAME: mockUserEntity.name, FEATCHAT: 'true', FEATTHER: 'false', PARTNERS: '', THERREMAIN: 0, THERREDEEM: 0, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, }, }); }); @@ -86,6 +97,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 +108,12 @@ 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, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, partners: partnerName, + last_active_at: lastActiveAt, feature_live_chat: mockPartnerAccessEntity.featureLiveChat, feature_therapy: mockPartnerAccessEntity.featureTherapy, therapy_sessions_remaining: mockPartnerAccessEntity.therapySessionsRemaining, @@ -120,24 +135,38 @@ describe('Service user profiles', () => { ], merge_fields: { SIGNUPD: mockUserEntity.createdAt.toISOString(), + LACTIVED: lastActiveAt, NAME: mockUserEntity.name, PARTNERS: partnerName, FEATCHAT: String(mockPartnerAccessEntity.featureLiveChat), FEATTHER: String(mockPartnerAccessEntity.featureTherapy), THERREMAIN: mockPartnerAccessEntity.therapySessionsRemaining, THERREDEEM: mockPartnerAccessEntity.therapySessionsRedeemed, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, }, }); }); + + 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); + const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); + expect(updateCrispProfile).toHaveBeenCalledWith( { marketing_permission: mockUserEntity.contactPermission, service_emails_permission: mockUserEntity.serviceEmailsPermission, + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + last_active_at: lastActiveAt, }, mockUserEntity.email, ); @@ -153,7 +182,11 @@ describe('Service user profiles', () => { enabled: mockUserEntity.contactPermission, }, ], - merge_fields: { NAME: mockUserEntity.name }, + merge_fields: { + NAME: mockUserEntity.name, + LACTIVED: lastActiveAt, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + }, }, mockUserEntity.email, ); @@ -165,6 +198,7 @@ describe('Service user profiles', () => { contactPermission: false, serviceEmailsPermission: false, }; + const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); await updateServiceUserProfilesUser(mockUser, false, mockUser.email); @@ -172,6 +206,8 @@ 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, ); @@ -187,7 +223,11 @@ describe('Service user profiles', () => { enabled: false, }, ], - merge_fields: { NAME: mockUser.name }, + merge_fields: { + NAME: mockUser.name, + LACTIVED: lastActiveAt, + REMINDFREQ: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + }, }, mockUser.email, ); @@ -209,6 +249,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 +335,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 +499,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 +535,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', () => { diff --git a/src/utils/serviceUserProfiles.ts b/src/utils/serviceUserProfiles.ts index 93de52ea..805847dc 100644 --- a/src/utils/serviceUserProfiles.ts +++ b/src/utils/serviceUserProfiles.ts @@ -214,11 +214,21 @@ const serializeCrispPartnerSegments = (partners: PartnerEntity[]) => { }; const serializeUserData = (user: UserEntity) => { - const { name, signUpLanguage, contactPermission, serviceEmailsPermission } = 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 }; @@ -232,7 +242,11 @@ const serializeUserData = (user: UserEntity) => { }, ], language: signUpLanguage || 'en', - merge_fields: { NAME: name }, + merge_fields: { + NAME: name, + LACTIVED: lastActiveAtString, + REMINDFREQ: emailRemindersFrequency, + }, } as ListMemberPartial; return { crispSchema, mailchimpSchema }; 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) { diff --git a/test/utils/mockData.ts b/test/utils/mockData.ts index 258e57bc..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 = { @@ -128,15 +132,17 @@ 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, serviceEmailsPermission: true, + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, email: 'user@email.com', name: 'name', signUpLanguage: 'en',