Skip to content

Commit b9dc864

Browse files
authored
feat: add lastActiveAt field to user (#475)
1 parent e14efe6 commit b9dc864

15 files changed

+84
-33
lines changed

src/api/crisp/crisp-api.interfaces.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export interface CrispProfileCustomFields {
22
signed_up_at?: string;
3+
last_active_at?: string;
34
language?: string;
45
marketing_permission?: boolean;
56
service_emails_permission?: boolean;

src/api/mailchimp/mailchimp-api.interfaces.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export enum MAILCHIMP_MERGE_FIELD_TYPES {
1515
export interface ListMemberCustomFields {
1616
NAME?: string;
1717
SIGNUPD?: string;
18+
LACTIVED?: string;
1819
PARTNERS?: string;
1920
FEATTHER?: string;
2021
FEATCHAT?: string;

src/entities/user.entity.ts

+3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export class UserEntity extends BaseBloomEntity {
3636
@Column({ type: Boolean, default: true })
3737
isActive: boolean;
3838

39+
@Column({ type: 'timestamptz', nullable: true })
40+
lastActiveAt: Date; // set each time user record is fetched
41+
3942
@OneToMany(() => PartnerAccessEntity, (partnerAccess) => partnerAccess.user, { cascade: true })
4043
partnerAccess: PartnerAccessEntity[];
4144

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class BloomBackend1718300621138 implements MigrationInterface {
4+
name = 'BloomBackend1718300621138'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`);
8+
await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" TIMESTAMP WITH TIME ZONE`);
9+
}
10+
11+
public async down(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveAt"`);
13+
await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveAt" date`);
14+
}
15+
16+
}

src/partner-admin/partner-admin-auth.guard.spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const userEntity: UserEntity = {
2222
partnerAccess: [],
2323
partnerAdmin: { id: 'partnerAdminId', active: true, partner: {} } as PartnerAdminEntity,
2424
isActive: true,
25+
lastActiveAt: new Date(),
2526
courseUser: [],
2627
signUpLanguage: 'en',
2728
subscriptionUser: [],

src/typeorm.config.ts

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { bloomBackend1696994943309 } from './migrations/1696994943309-bloom-back
4444
import { bloomBackend1697818259254 } from './migrations/1697818259254-bloom-backend';
4545
import { bloomBackend1698136145516 } from './migrations/1698136145516-bloom-backend';
4646
import { bloomBackend1706174260018 } from './migrations/1706174260018-bloom-backend';
47+
import { BloomBackend1718300621138 } from './migrations/1718300621138-bloom-backend';
4748

4849
config();
4950
const configService = new ConfigService();
@@ -108,6 +109,7 @@ export const dataSourceOptions = {
108109
bloomBackend1697818259254,
109110
bloomBackend1698136145516,
110111
bloomBackend1706174260018,
112+
BloomBackend1718300621138,
111113
],
112114
subscribers: [],
113115
ssl: isProduction,

src/user/dtos/update-user.dto.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
import { IsBoolean, IsOptional, IsString } from 'class-validator';
2+
import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator';
33

44
export class UpdateUserDto {
55
@IsString()
@@ -21,4 +21,9 @@ export class UpdateUserDto {
2121
@IsOptional()
2222
@ApiProperty({ type: String })
2323
signUpLanguage: string;
24+
25+
@IsDate()
26+
@IsOptional()
27+
@ApiProperty({ type: 'date' })
28+
lastActiveAt: Date;
2429
}

src/user/user.controller.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,15 @@ export class UserController {
4444
@Get('/me')
4545
@UseGuards(FirebaseAuthGuard)
4646
async getUserByFirebaseId(@Req() req: Request): Promise<GetUserDto> {
47-
return req['user'];
47+
const user = req['user'];
48+
this.userService.updateUser({ lastActiveAt: new Date() }, user);
49+
return user;
4850
}
4951

5052
/**
5153
* This POST endpoint deviates from REST patterns.
5254
* Please use `getUserByFirebaseId` above which is a GET endpoint.
53-
* Do not delete this until frontend usage is migrated.
55+
* Safe to delete function below from July 2024 - allowing for caches to clear
5456
*/
5557
@ApiBearerAuth('access-token')
5658
@ApiOperation({

src/user/user.interface.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface IUser {
66
name: string;
77
email: string;
88
isActive: boolean;
9+
lastActiveAt: Date | string;
910
crispTokenId: string;
1011
isSuperAdmin: boolean;
1112
signUpLanguage: string;

src/user/user.service.spec.ts

+16-18
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,7 @@ const createUserDto: CreateUserDto = {
4242
signUpLanguage: 'en',
4343
};
4444

45-
const createUserRepositoryDto = {
46-
47-
password: 'password',
48-
name: 'name',
49-
contactPermission: false,
50-
serviceEmailsPermission: true,
51-
signUpLanguage: 'en',
52-
firebaseUid: mockUserRecord.uid,
53-
};
54-
55-
const updateUserDto: UpdateUserDto = {
45+
const updateUserDto: Partial<UpdateUserDto> = {
5646
name: 'new name',
5747
contactPermission: true,
5848
serviceEmailsPermission: false,
@@ -123,7 +113,12 @@ describe('UserService', () => {
123113
const repoSaveSpy = jest.spyOn(repo, 'save');
124114

125115
const user = await service.createUser(createUserDto);
126-
expect(repoSaveSpy).toHaveBeenCalledWith(createUserRepositoryDto);
116+
expect(repoSaveSpy).toHaveBeenCalledWith({
117+
...createUserDto,
118+
firebaseUid: mockUserRecord.uid,
119+
lastActiveAt: user.user.lastActiveAt,
120+
});
121+
127122
expect(user.user.email).toBe('[email protected]');
128123
expect(user.partnerAdmin).toBeNull();
129124
expect(user.partnerAccesses).toBeNull();
@@ -135,6 +130,7 @@ describe('UserService', () => {
135130
segments: ['public'],
136131
});
137132
expect(updateCrispProfile).toHaveBeenCalled();
133+
expect(createMailchimpProfile).toHaveBeenCalled();
138134
});
139135

140136
it('when supplied with user dto and partner access code, it should return a new partner user', async () => {
@@ -169,6 +165,7 @@ describe('UserService', () => {
169165
expect(updateCrispProfile).toHaveBeenCalledWith(
170166
{
171167
signed_up_at: user.user.createdAt,
168+
last_active_at: (user.user.lastActiveAt as Date).toISOString(),
172169
marketing_permission: true,
173170
service_emails_permission: true,
174171
partners: 'bumble',
@@ -179,6 +176,7 @@ describe('UserService', () => {
179176
},
180177
181178
);
179+
expect(createMailchimpProfile).toHaveBeenCalled();
182180
});
183181

184182
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', () => {
228226
]);
229227
});
230228

231-
it('should not fail on crisp api call errors', async () => {
229+
it('should not fail create on crisp api call errors', async () => {
232230
const mocked = jest.mocked(createCrispProfile);
233231
mocked.mockRejectedValue(new Error('Crisp API call failed'));
234232

@@ -240,7 +238,7 @@ describe('UserService', () => {
240238
mocked.mockReset();
241239
});
242240

243-
it('should not fail on mailchimp api call errors', async () => {
241+
it('should not fail create on mailchimp api call errors', async () => {
244242
const mocked = jest.mocked(createMailchimpProfile);
245243
mocked.mockRejectedValue(new Error('Mailchimp API call failed'));
246244

@@ -290,25 +288,25 @@ describe('UserService', () => {
290288
expect(repoSaveSpy).toHaveBeenCalled();
291289
});
292290

293-
it('should not fail on crisp api call errors', async () => {
291+
it('should not fail update on crisp api call errors', async () => {
294292
const mocked = jest.mocked(updateCrispProfile);
295293
mocked.mockRejectedValue(new Error('Crisp API call failed'));
296294

297295
const user = await service.updateUser(updateUserDto, { user: mockUserEntity });
298-
296+
await new Promise(process.nextTick); // wait for async funcs to resolve
299297
expect(mocked).toHaveBeenCalled();
300298
expect(user.name).toBe('new name');
301299
expect(user.email).toBe('[email protected]');
302300

303301
mocked.mockReset();
304302
});
305303

306-
it('should not fail on mailchimp api call errors', async () => {
304+
it('should not fail update on mailchimp api call errors', async () => {
307305
const mocked = jest.mocked(updateMailchimpProfile);
308306
mocked.mockRejectedValue(new Error('Mailchimp API call failed'));
309307

310308
const user = await service.updateUser(updateUserDto, { user: mockUserEntity });
311-
309+
await new Promise(process.nextTick); // wait for async funcs to resolve
312310
expect(mocked).toHaveBeenCalled();
313311
expect(user.name).toBe('new name');
314312
expect(user.email).toBe('[email protected]');

src/user/user.service.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class UserService {
6767

6868
const user = await this.userRepository.save({
6969
...createUserDto,
70+
lastActiveAt: new Date(),
7071
firebaseUid: firebaseUser.uid,
7172
});
7273

@@ -190,7 +191,7 @@ export class UserService {
190191
return await this.deleteUser(user);
191192
}
192193

193-
public async updateUser(updateUserDto: UpdateUserDto, { user: { id } }: GetUserDto) {
194+
public async updateUser(updateUserDto: Partial<UpdateUserDto>, { user: { id } }: GetUserDto) {
194195
const user = await this.userRepository.findOneBy({ id });
195196

196197
if (!user) {
@@ -203,9 +204,10 @@ export class UserService {
203204
};
204205
const updatedUser = await this.userRepository.save(newUserData);
205206

206-
const isNameOrLanguageUpdated =
207-
user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name;
208-
updateServiceUserProfilesUser(user, isNameOrLanguageUpdated, user.email);
207+
const isCrispBaseUpdateRequired =
208+
(user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name) ||
209+
user.lastActiveAt !== updateUserDto.lastActiveAt;
210+
updateServiceUserProfilesUser(user, isCrispBaseUpdateRequired, user.email);
209211

210212
return updatedUser;
211213
}

src/utils/serialize.ts

+2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const formatUserObject = (userObject: UserEntity): GetUserDto => {
8787
email: userObject.email,
8888
firebaseUid: userObject.firebaseUid,
8989
isActive: userObject.isActive,
90+
lastActiveAt: userObject.lastActiveAt,
9091
crispTokenId: userObject.crispTokenId,
9192
isSuperAdmin: userObject.isSuperAdmin,
9293
signUpLanguage: userObject.signUpLanguage,
@@ -122,6 +123,7 @@ export const formatGetUsersObject = (userObject: UserEntity): GetUserDto => {
122123
email: userObject.email,
123124
firebaseUid: userObject.firebaseUid,
124125
isActive: userObject.isActive,
126+
lastActiveAt: userObject.lastActiveAt,
125127
crispTokenId: userObject.crispTokenId,
126128
isSuperAdmin: userObject.isSuperAdmin,
127129
signUpLanguage: userObject.signUpLanguage,

src/utils/serviceUserProfiles.spec.ts

+19-5
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,15 @@ describe('Service user profiles', () => {
4545
segments: ['public'],
4646
});
4747

48+
const createdAt = mockUserEntity.createdAt.toISOString();
49+
const lastActiveAt = mockUserEntity.lastActiveAt.toISOString();
50+
4851
expect(updateCrispProfile).toHaveBeenCalledWith(
4952
{
5053
marketing_permission: mockUserEntity.contactPermission,
5154
service_emails_permission: mockUserEntity.serviceEmailsPermission,
52-
signed_up_at: mockUserEntity.createdAt.toISOString(),
55+
signed_up_at: createdAt,
56+
last_active_at: lastActiveAt,
5357
feature_live_chat: true,
5458
feature_therapy: false,
5559
partners: '',
@@ -71,7 +75,8 @@ describe('Service user profiles', () => {
7175
},
7276
],
7377
merge_fields: {
74-
SIGNUPD: mockUserEntity.createdAt.toISOString(),
78+
SIGNUPD: createdAt,
79+
LACTIVED: lastActiveAt,
7580
NAME: mockUserEntity.name,
7681
FEATCHAT: 'true',
7782
FEATTHER: 'false',
@@ -86,6 +91,8 @@ describe('Service user profiles', () => {
8691
await createServiceUserProfiles(mockUserEntity, mockPartnerEntity, mockPartnerAccessEntity);
8792

8893
const partnerName = mockPartnerEntity.name.toLowerCase();
94+
const createdAt = mockUserEntity.createdAt.toISOString();
95+
const lastActiveAt = mockUserEntity.lastActiveAt.toISOString();
8996

9097
expect(createCrispProfile).toHaveBeenCalledWith({
9198
email: mockUserEntity.email,
@@ -95,10 +102,11 @@ describe('Service user profiles', () => {
95102

96103
expect(updateCrispProfile).toHaveBeenCalledWith(
97104
{
98-
signed_up_at: mockUserEntity.createdAt.toISOString(),
105+
signed_up_at: createdAt,
99106
marketing_permission: mockUserEntity.contactPermission,
100107
service_emails_permission: mockUserEntity.serviceEmailsPermission,
101108
partners: partnerName,
109+
last_active_at: lastActiveAt,
102110
feature_live_chat: mockPartnerAccessEntity.featureLiveChat,
103111
feature_therapy: mockPartnerAccessEntity.featureTherapy,
104112
therapy_sessions_remaining: mockPartnerAccessEntity.therapySessionsRemaining,
@@ -120,6 +128,7 @@ describe('Service user profiles', () => {
120128
],
121129
merge_fields: {
122130
SIGNUPD: mockUserEntity.createdAt.toISOString(),
131+
LACTIVED: lastActiveAt,
123132
NAME: mockUserEntity.name,
124133
PARTNERS: partnerName,
125134
FEATCHAT: String(mockPartnerAccessEntity.featureLiveChat),
@@ -142,10 +151,13 @@ describe('Service user profiles', () => {
142151
it('should update crisp and mailchimp profile user data', async () => {
143152
await updateServiceUserProfilesUser(mockUserEntity, false, mockUserEntity.email);
144153

154+
const lastActiveAt = mockUserEntity.lastActiveAt.toISOString();
155+
145156
expect(updateCrispProfile).toHaveBeenCalledWith(
146157
{
147158
marketing_permission: mockUserEntity.contactPermission,
148159
service_emails_permission: mockUserEntity.serviceEmailsPermission,
160+
last_active_at: lastActiveAt,
149161
},
150162
mockUserEntity.email,
151163
);
@@ -161,7 +173,7 @@ describe('Service user profiles', () => {
161173
enabled: mockUserEntity.contactPermission,
162174
},
163175
],
164-
merge_fields: { NAME: mockUserEntity.name },
176+
merge_fields: { NAME: mockUserEntity.name, LACTIVED: lastActiveAt },
165177
},
166178
mockUserEntity.email,
167179
);
@@ -173,13 +185,15 @@ describe('Service user profiles', () => {
173185
contactPermission: false,
174186
serviceEmailsPermission: false,
175187
};
188+
const lastActiveAt = mockUserEntity.lastActiveAt.toISOString();
176189

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

179192
expect(updateCrispProfile).toHaveBeenCalledWith(
180193
{
181194
marketing_permission: false,
182195
service_emails_permission: false,
196+
last_active_at: lastActiveAt,
183197
},
184198
mockUser.email,
185199
);
@@ -195,7 +209,7 @@ describe('Service user profiles', () => {
195209
enabled: false,
196210
},
197211
],
198-
merge_fields: { NAME: mockUser.name },
212+
merge_fields: { NAME: mockUser.name, LACTIVED: lastActiveAt },
199213
},
200214
mockUser.email,
201215
);

src/utils/serviceUserProfiles.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,13 @@ const serializeCrispPartnerSegments = (partners: PartnerEntity[]) => {
214214
};
215215

216216
const serializeUserData = (user: UserEntity) => {
217-
const { name, signUpLanguage, contactPermission, serviceEmailsPermission } = user;
217+
const { name, signUpLanguage, contactPermission, serviceEmailsPermission, lastActiveAt } = user;
218+
const lastActiveAtString = lastActiveAt?.toISOString() || '';
218219

219220
const crispSchema = {
220221
marketing_permission: contactPermission,
221222
service_emails_permission: serviceEmailsPermission,
223+
last_active_at: lastActiveAtString,
222224
// Name and language handled on base level profile for crisp
223225
};
224226

@@ -232,7 +234,7 @@ const serializeUserData = (user: UserEntity) => {
232234
},
233235
],
234236
language: signUpLanguage || 'en',
235-
merge_fields: { NAME: name },
237+
merge_fields: { NAME: name, LACTIVED: lastActiveAtString },
236238
} as ListMemberPartial;
237239

238240
return { crispSchema, mailchimpSchema };

0 commit comments

Comments
 (0)