Skip to content

Commit ac12681

Browse files
authored
Bulk upload mailchimp users (#450)
1 parent 05f4cd7 commit ac12681

8 files changed

+121
-59
lines changed

README.md

+6-4
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ For a more detailed explanation of this project's key concepts and architecture,
4242
- [Slack](https://api.slack.com/messaging/webhooks) - Slack webhooks to send messages to the team
4343
- [Rollbar](https://rollbar.com/) - Error reporting
4444
- [Crisp](https://crisp.chat/en/) - User messaging
45+
- [Mailchimp](https://mailchimp.com/developer/marketing/) - Transactional email
4546
- [Docker](https://www.docker.com/) - Containers for api and db
4647
- [Heroku](https://heroku.com) - Build, deploy and operate staging and production apps
4748
- [GitHub Actions](https://github.com/features/actions) - CI pipeline
@@ -58,14 +59,15 @@ For a more detailed explanation of this project's key concepts and architecture,
5859

5960
**Recommended for Visual Studio & Visual Studio Code users.**
6061

61-
This method will automatically install all dependencies and IDE settings in a Dev Container (Docker container) within Visual Studio Code.
62+
This method will automatically install all dependencies and IDE settings in a Dev Container (Docker container) within Visual Studio Code.
6263

6364
Directions for running a dev container:
65+
6466
1. Meet the [system requirements](https://code.visualstudio.com/docs/devcontainers/containers#_system-requirements)
6567
2. Follow the [installation instructions](https://code.visualstudio.com/docs/devcontainers/containers#_installation)
6668
3. [Check the installation](https://code.visualstudio.com/docs/devcontainers/tutorial#_check-installation)
6769
4. After you've verified that the extension is installed and working, click on the "Remote Status" bar icon and select
68-
"Reopen in Container". From here, the option to "Re-open in Container" should pop up in notifications whenever opening this project in VS.
70+
"Reopen in Container". From here, the option to "Re-open in Container" should pop up in notifications whenever opening this project in VS.
6971
5. [Configure your environment variables](#configure-environment-variables) and develop as you normally would.
7072

7173
The dev Container is configured in the `.devcontainer` directory:
@@ -84,8 +86,8 @@ yarn
8486
### Configure Environment Variables
8587

8688
Create a new `.env` file and populate it with the variables below. Note that only the Firebase and Simplybook tokens are required.
87-
To configure the Firebase variables, first [create a Firebase project in the Firebase console](https://firebase.google.com/) (Google account required).
88-
Next, follow [these directions](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) to generate a private key file in JSON format.
89+
To configure the Firebase variables, first [create a Firebase project in the Firebase console](https://firebase.google.com/) (Google account required).
90+
Next, follow [these directions](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) to generate a private key file in JSON format.
8991
These will generate all the required Firebase variables.
9092

9193
The Simplybook variables can be mocked data, meaning **you do not need to use real Simplybook variables, simply copy paste the values given below.**

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ export interface CrispProfileCustomFields {
1010
therapy_sessions_redeemed?: number;
1111
course_hst?: string;
1212
course_hst_sessions?: string;
13-
course_pst?: string;
14-
course_pst_sessions?: string;
13+
course_spst?: string;
14+
course_spst_sessions?: string;
1515
course_dbr?: string;
1616
course_dbr_sessions?: string;
1717
course_iaro?: string;

src/api/mailchimp/mailchimp-api.ts

+32
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import mailchimp from '@mailchimp/mailchimp_marketing';
22
import { createHash } from 'crypto';
3+
import { UserEntity } from 'src/entities/user.entity';
34
import { mailchimpApiKey, mailchimpAudienceId, mailchimpServerPrefix } from 'src/utils/constants';
5+
import { createCompleteMailchimpUserProfile } from 'src/utils/serviceUserProfiles';
46
import {
57
ListMember,
68
ListMemberPartial,
@@ -32,6 +34,36 @@ export const createMailchimpProfile = async (
3234
}
3335
};
3436

37+
export const batchCreateMailchimpProfiles = async (users: UserEntity[]) => {
38+
try {
39+
const operations = [];
40+
41+
users.forEach((user) => {
42+
const profileData = createCompleteMailchimpUserProfile(user);
43+
operations.push({
44+
method: 'POST',
45+
path: `/lists/${mailchimpAudienceId}/members`,
46+
operation_id: user.id,
47+
body: JSON.stringify(profileData),
48+
});
49+
});
50+
51+
const batchRequest = await mailchimp.batches.start({
52+
operations: operations,
53+
});
54+
console.log('Mailchimp batch request:', batchRequest);
55+
console.log('Wait 2 minutes before calling response...');
56+
57+
setTimeout(async () => {
58+
const batchResponse = await mailchimp.batches.status(batchRequest.id);
59+
console.log('Mailchimp batch response:', batchResponse);
60+
}, 120000);
61+
} catch (error) {
62+
console.log(error);
63+
throw new Error(`Batch create mailchimp profiles API call failed: ${error}`);
64+
}
65+
};
66+
3567
// Note getMailchimpProfile is not currently used
3668
export const getMailchimpProfile = async (email: string): Promise<ListMember> => {
3769
try {

src/user/user.controller.ts

+8
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,12 @@ export class UserController {
109109
: { include: [], fields: [], limit: undefined };
110110
return await this.userService.getUsers(userQuery, include, fields, limit);
111111
}
112+
113+
// Use only if users have not been added to mailchimp due to e.g. an ongoing bug
114+
@ApiBearerAuth()
115+
@Post('/bulk-mailchimp-upload')
116+
@UseGuards(FirebaseAuthGuard)
117+
async bulkUploadMailchimpProfiles() {
118+
return await this.userService.bulkUploadMailchimpProfiles();
119+
}
112120
}

src/user/user.service.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
3+
import { batchCreateMailchimpProfiles } from 'src/api/mailchimp/mailchimp-api';
34
import { PartnerAccessEntity } from 'src/entities/partner-access.entity';
45
import { PartnerEntity } from 'src/entities/partner.entity';
56
import { UserEntity } from 'src/entities/user.entity';
@@ -12,7 +13,7 @@ import {
1213
createServiceUserProfiles,
1314
updateServiceUserProfilesUser,
1415
} from 'src/utils/serviceUserProfiles';
15-
import { ILike, Repository } from 'typeorm';
16+
import { And, ILike, Raw, Repository } from 'typeorm';
1617
import { deleteCypressCrispProfiles } from '../api/crisp/crisp-api';
1718
import { AuthService } from '../auth/auth.service';
1819
import { PartnerAccessService, basePartnerAccess } from '../partner-access/partner-access.service';
@@ -298,4 +299,35 @@ export class UserService {
298299
const usersDto = users.map((user) => formatGetUsersObject(user));
299300
return usersDto;
300301
}
302+
303+
// Static bulk upload function to be used in specific cases
304+
// UPDATE THE FILTERS to the current requirements
305+
public async bulkUploadMailchimpProfiles() {
306+
try {
307+
const filterStartDate = '2023-01-01'; // UPDATE
308+
const filterEndDate = '2024-01-01'; // UPDATE
309+
const users = await this.userRepository.find({
310+
where: {
311+
// UPDATE TO ANY FILTERS
312+
createdAt: And(
313+
Raw((alias) => `${alias} >= :filterStartDate`, { filterStartDate: filterStartDate }),
314+
Raw((alias) => `${alias} < :filterEndDate`, { filterEndDate: filterEndDate }),
315+
),
316+
},
317+
relations: {
318+
partnerAccess: { partner: true, therapySession: true },
319+
courseUser: { course: true, sessionUser: { session: true } },
320+
},
321+
});
322+
const usersWithCourseUsers = users.filter((user) => user.courseUser.length > 0);
323+
324+
console.log(usersWithCourseUsers);
325+
await batchCreateMailchimpProfiles(usersWithCourseUsers);
326+
this.logger.log(
327+
`Created batch mailchimp profiles for ${usersWithCourseUsers.length} users, created before ${filterStartDate}`,
328+
);
329+
} catch (error) {
330+
throw new Error(`Bulk upload mailchimp profiles API call failed: ${error}`);
331+
}
332+
}
301333
}

src/utils/serviceUserProfiles.spec.ts

+4-24
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,7 @@ describe('Service user profiles', () => {
300300
},
301301
];
302302

303-
await updateServiceUserProfilesTherapy(
304-
partnerAccesses,
305-
SIMPLYBOOK_ACTION_ENUM.NEW_BOOKING,
306-
therapySession.startDateTime,
307-
mockUserEntity.email,
308-
);
303+
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);
309304

310305
const firstTherapySessionAt = therapySession.startDateTime.toISOString();
311306
const nextTherapySessionAt = therapySession.startDateTime.toISOString();
@@ -339,12 +334,7 @@ describe('Service user profiles', () => {
339334
it('should update crisp and mailchimp profile combined therapy data for new booking', async () => {
340335
const partnerAccesses = [mockPartnerAccessEntity, mockAltPartnerAccessEntity];
341336

342-
await updateServiceUserProfilesTherapy(
343-
partnerAccesses,
344-
SIMPLYBOOK_ACTION_ENUM.NEW_BOOKING,
345-
mockAltPartnerAccessEntity.therapySession[1].startDateTime,
346-
mockUserEntity.email,
347-
);
337+
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);
348338

349339
const firstTherapySessionAt =
350340
mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString();
@@ -381,12 +371,7 @@ describe('Service user profiles', () => {
381371
it('should update crisp and mailchimp profile combined therapy data for updated booking', async () => {
382372
const partnerAccesses = [mockPartnerAccessEntity, mockAltPartnerAccessEntity];
383373

384-
await updateServiceUserProfilesTherapy(
385-
partnerAccesses,
386-
SIMPLYBOOK_ACTION_ENUM.UPDATED_BOOKING,
387-
mockAltPartnerAccessEntity.therapySession[1].startDateTime,
388-
mockUserEntity.email,
389-
);
374+
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);
390375

391376
const firstTherapySessionAt =
392377
mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString();
@@ -425,12 +410,7 @@ describe('Service user profiles', () => {
425410
SIMPLYBOOK_ACTION_ENUM.CANCELLED_BOOKING;
426411
const partnerAccesses = [mockPartnerAccessEntity, mockAltPartnerAccessEntity];
427412

428-
await updateServiceUserProfilesTherapy(
429-
partnerAccesses,
430-
SIMPLYBOOK_ACTION_ENUM.CANCELLED_BOOKING,
431-
mockAltPartnerAccessEntity.therapySession[1].startDateTime,
432-
mockUserEntity.email,
433-
);
413+
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);
434414

435415
const firstTherapySessionAt =
436416
mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString();

src/utils/serviceUserProfiles.ts

+34-16
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,10 @@ export const updateServiceUserProfilesPartnerAccess = async (
126126

127127
export const updateServiceUserProfilesTherapy = async (
128128
partnerAccesses: PartnerAccessEntity[],
129-
therapySessionAction: SIMPLYBOOK_ACTION_ENUM,
130-
therapySessionDate: Date,
131129
email,
132130
) => {
133131
try {
134-
const therapyData = serializeTherapyData(
135-
partnerAccesses,
136-
therapySessionAction,
137-
therapySessionDate,
138-
);
132+
const therapyData = serializeTherapyData(partnerAccesses);
139133
await updateCrispProfile(therapyData.crispSchema, email);
140134
await updateMailchimpProfile(therapyData.mailchimpSchema, email);
141135
} catch (error) {
@@ -181,6 +175,36 @@ export const createMailchimpCourseMergeField = async (courseName: string) => {
181175
}
182176
};
183177

178+
// Currently only used in bulk upload function, as mailchimp profiles are typically built
179+
// incrementally on sign up and subsequent user actions
180+
export const createCompleteMailchimpUserProfile = (user: UserEntity): ListMemberPartial => {
181+
const userData = serializeUserData(user);
182+
const partnerData = serializePartnerAccessData(user.partnerAccess);
183+
const therapyData = serializeTherapyData(user.partnerAccess);
184+
185+
const courseData = {};
186+
user.courseUser.forEach((courseUser) => {
187+
const courseUserData = serializeCourseData(courseUser);
188+
Object.keys(courseUserData.mailchimpSchema.merge_fields).forEach((key) => {
189+
courseData[key] = courseUserData.mailchimpSchema.merge_fields[key];
190+
});
191+
});
192+
193+
const profileData = {
194+
email_address: user.email,
195+
...userData.mailchimpSchema,
196+
197+
merge_fields: {
198+
SIGNUPD: user.createdAt?.toISOString(),
199+
...userData.mailchimpSchema.merge_fields,
200+
...partnerData.mailchimpSchema.merge_fields,
201+
...therapyData.mailchimpSchema.merge_fields,
202+
...courseData,
203+
},
204+
};
205+
return profileData;
206+
};
207+
184208
export const serializePartnersString = (partnerAccesses: PartnerAccessEntity[]) => {
185209
return partnerAccesses?.map((pa) => pa.partner.name.toLowerCase()).join('; ') || '';
186210
};
@@ -203,7 +227,7 @@ const serializeUserData = (user: UserEntity) => {
203227
enabled: contactPermission,
204228
},
205229
],
206-
language: signUpLanguage,
230+
language: signUpLanguage || 'en',
207231
merge_fields: { NAME: name },
208232
} as ListMemberPartial;
209233

@@ -254,20 +278,14 @@ const serializePartnerAccessData = (partnerAccesses: PartnerAccessEntity[]) => {
254278
return { crispSchema, mailchimpSchema };
255279
};
256280

257-
const serializeTherapyData = (
258-
partnerAccesses: PartnerAccessEntity[],
259-
therapySessionAction: SIMPLYBOOK_ACTION_ENUM,
260-
therapySessionDate: Date,
261-
) => {
281+
const serializeTherapyData = (partnerAccesses: PartnerAccessEntity[]) => {
262282
const therapySessions = partnerAccesses
263283
.flatMap((partnerAccess) => partnerAccess.therapySession)
264284
.filter((therapySession) => therapySession.action !== SIMPLYBOOK_ACTION_ENUM.CANCELLED_BOOKING)
265285
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
266286

267287
const pastTherapySessions = therapySessions.filter(
268-
(therapySession) =>
269-
therapySession.startDateTime !== therapySessionDate &&
270-
therapySession.startDateTime.getTime() < new Date().getTime(),
288+
(therapySession) => therapySession.startDateTime.getTime() < new Date().getTime(),
271289
);
272290
const futureTherapySessions = therapySessions.filter(
273291
(therapySession) => therapySession.startDateTime.getTime() > new Date().getTime(),

src/webhooks/webhooks.service.ts

+2-12
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,7 @@ export class WebhooksService {
128128
},
129129
});
130130

131-
updateServiceUserProfilesTherapy(
132-
[...partnerAccesses],
133-
action,
134-
therapySession.startDateTime,
135-
user.email,
136-
);
131+
updateServiceUserProfilesTherapy([...partnerAccesses], user.email);
137132

138133
this.logger.log(
139134
`Update therapy session webhook function COMPLETED for ${action} - ${user.email} - ${booking_code} - userId ${user_id}`,
@@ -249,12 +244,7 @@ export class WebhooksService {
249244
await this.partnerAccessRepository.save(partnerAccess);
250245
const therapySession = await this.therapySessionRepository.save(serializedTherapySession);
251246

252-
updateServiceUserProfilesTherapy(
253-
[...partnerAccesses, partnerAccess],
254-
SIMPLYBOOK_ACTION_ENUM.NEW_BOOKING,
255-
therapySession.startDateTime,
256-
user.email,
257-
);
247+
updateServiceUserProfilesTherapy([...partnerAccesses, partnerAccess], user.email);
258248

259249
return therapySession;
260250
} catch (err) {

0 commit comments

Comments
 (0)