Skip to content

Commit

Permalink
feat: add lastActiveAt field to user (#475)
Browse files Browse the repository at this point in the history
  • Loading branch information
annarhughes committed Jun 18, 2024
1 parent e14efe6 commit b9dc864
Show file tree
Hide file tree
Showing 15 changed files with 84 additions and 33 deletions.
1 change: 1 addition & 0 deletions src/api/crisp/crisp-api.interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface CrispProfileCustomFields {
signed_up_at?: string;
last_active_at?: string;
language?: string;
marketing_permission?: boolean;
service_emails_permission?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/api/mailchimp/mailchimp-api.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down
16 changes: 16 additions & 0 deletions src/migrations/1718300621138-bloom-backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class BloomBackend1718300621138 implements MigrationInterface {
name = 'BloomBackend1718300621138'

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`);
await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" date`);
}

}
1 change: 1 addition & 0 deletions src/partner-admin/partner-admin-auth.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions src/typeorm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -108,6 +109,7 @@ export const dataSourceOptions = {
bloomBackend1697818259254,
bloomBackend1698136145516,
bloomBackend1706174260018,
BloomBackend1718300621138,
],
subscribers: [],
ssl: isProduction,
Expand Down
7 changes: 6 additions & 1 deletion src/user/dtos/update-user.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -21,4 +21,9 @@ export class UpdateUserDto {
@IsOptional()
@ApiProperty({ type: String })
signUpLanguage: string;

@IsDate()
@IsOptional()
@ApiProperty({ type: 'date' })
lastActiveAt: Date;
}
6 changes: 4 additions & 2 deletions src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ export class UserController {
@Get('/me')
@UseGuards(FirebaseAuthGuard)
async getUserByFirebaseId(@Req() req: Request): Promise<GetUserDto> {
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({
Expand Down
1 change: 1 addition & 0 deletions src/user/user.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface IUser {
name: string;
email: string;
isActive: boolean;
lastActiveAt: Date | string;
crispTokenId: string;
isSuperAdmin: boolean;
signUpLanguage: string;
Expand Down
34 changes: 16 additions & 18 deletions src/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateUserDto> = {
name: 'new name',
contactPermission: true,
serviceEmailsPermission: false,
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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'));

Expand All @@ -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'));

Expand Down Expand Up @@ -290,25 +288,25 @@ 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');

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');
Expand Down
10 changes: 6 additions & 4 deletions src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class UserService {

const user = await this.userRepository.save({
...createUserDto,
lastActiveAt: new Date(),
firebaseUid: firebaseUser.uid,
});

Expand Down Expand Up @@ -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<UpdateUserDto>, { user: { id } }: GetUserDto) {
const user = await this.userRepository.findOneBy({ id });

if (!user) {
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/utils/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 19 additions & 5 deletions src/utils/serviceUserProfiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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,
);
Expand All @@ -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,
);
Expand All @@ -173,13 +185,15 @@ describe('Service user profiles', () => {
contactPermission: false,
serviceEmailsPermission: false,
};
const lastActiveAt = mockUserEntity.lastActiveAt.toISOString();

await updateServiceUserProfilesUser(mockUser, false, mockUser.email);

expect(updateCrispProfile).toHaveBeenCalledWith(
{
marketing_permission: false,
service_emails_permission: false,
last_active_at: lastActiveAt,
},
mockUser.email,
);
Expand All @@ -195,7 +209,7 @@ describe('Service user profiles', () => {
enabled: false,
},
],
merge_fields: { NAME: mockUser.name },
merge_fields: { NAME: mockUser.name, LACTIVED: lastActiveAt },
},
mockUser.email,
);
Expand Down
6 changes: 4 additions & 2 deletions src/utils/serviceUserProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand All @@ -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 };
Expand Down
Loading

0 comments on commit b9dc864

Please sign in to comment.