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,