Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge Develop onto Main #451

Merged
merged 1 commit into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ For a more detailed explanation of this project's key concepts and architecture,
- [Slack](https://api.slack.com/messaging/webhooks) - Slack webhooks to send messages to the team
- [Rollbar](https://rollbar.com/) - Error reporting
- [Crisp](https://crisp.chat/en/) - User messaging
- [Mailchimp](https://mailchimp.com/developer/marketing/) - Transactional email
- [Docker](https://www.docker.com/) - Containers for api and db
- [Heroku](https://heroku.com) - Build, deploy and operate staging and production apps
- [GitHub Actions](https://github.com/features/actions) - CI pipeline
Expand All @@ -58,14 +59,15 @@ For a more detailed explanation of this project's key concepts and architecture,

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

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

Directions for running a dev container:

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

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

Create a new `.env` file and populate it with the variables below. Note that only the Firebase and Simplybook tokens are required.
To configure the Firebase variables, first [create a Firebase project in the Firebase console](https://firebase.google.com/) (Google account required).
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.
To configure the Firebase variables, first [create a Firebase project in the Firebase console](https://firebase.google.com/) (Google account required).
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.
These will generate all the required Firebase variables.

The Simplybook variables can be mocked data, meaning **you do not need to use real Simplybook variables, simply copy paste the values given below.**
Expand Down
4 changes: 2 additions & 2 deletions src/api/crisp/crisp-api.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export interface CrispProfileCustomFields {
therapy_sessions_redeemed?: number;
course_hst?: string;
course_hst_sessions?: string;
course_pst?: string;
course_pst_sessions?: string;
course_spst?: string;
course_spst_sessions?: string;
course_dbr?: string;
course_dbr_sessions?: string;
course_iaro?: string;
Expand Down
32 changes: 32 additions & 0 deletions src/api/mailchimp/mailchimp-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import mailchimp from '@mailchimp/mailchimp_marketing';
import { createHash } from 'crypto';
import { UserEntity } from 'src/entities/user.entity';
import { mailchimpApiKey, mailchimpAudienceId, mailchimpServerPrefix } from 'src/utils/constants';
import { createCompleteMailchimpUserProfile } from 'src/utils/serviceUserProfiles';
import {
ListMember,
ListMemberPartial,
Expand Down Expand Up @@ -32,6 +34,36 @@ export const createMailchimpProfile = async (
}
};

export const batchCreateMailchimpProfiles = async (users: UserEntity[]) => {
try {
const operations = [];

users.forEach((user) => {
const profileData = createCompleteMailchimpUserProfile(user);
operations.push({
method: 'POST',
path: `/lists/${mailchimpAudienceId}/members`,
operation_id: user.id,
body: JSON.stringify(profileData),
});
});

const batchRequest = await mailchimp.batches.start({
operations: operations,
});
console.log('Mailchimp batch request:', batchRequest);
console.log('Wait 2 minutes before calling response...');

setTimeout(async () => {
const batchResponse = await mailchimp.batches.status(batchRequest.id);
console.log('Mailchimp batch response:', batchResponse);
}, 120000);
} catch (error) {
console.log(error);
throw new Error(`Batch create mailchimp profiles API call failed: ${error}`);
}
};

// Note getMailchimpProfile is not currently used
export const getMailchimpProfile = async (email: string): Promise<ListMember> => {
try {
Expand Down
8 changes: 8 additions & 0 deletions src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,12 @@ export class UserController {
: { include: [], fields: [], limit: undefined };
return await this.userService.getUsers(userQuery, include, fields, limit);
}

// Use only if users have not been added to mailchimp due to e.g. an ongoing bug
@ApiBearerAuth()
@Post('/bulk-mailchimp-upload')
@UseGuards(FirebaseAuthGuard)
async bulkUploadMailchimpProfiles() {
return await this.userService.bulkUploadMailchimpProfiles();
}
}
34 changes: 33 additions & 1 deletion src/user/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { batchCreateMailchimpProfiles } from 'src/api/mailchimp/mailchimp-api';
import { PartnerAccessEntity } from 'src/entities/partner-access.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { UserEntity } from 'src/entities/user.entity';
Expand All @@ -12,7 +13,7 @@ import {
createServiceUserProfiles,
updateServiceUserProfilesUser,
} from 'src/utils/serviceUserProfiles';
import { ILike, Repository } from 'typeorm';
import { And, ILike, Raw, Repository } from 'typeorm';
import { deleteCypressCrispProfiles } from '../api/crisp/crisp-api';
import { AuthService } from '../auth/auth.service';
import { PartnerAccessService, basePartnerAccess } from '../partner-access/partner-access.service';
Expand Down Expand Up @@ -298,4 +299,35 @@ export class UserService {
const usersDto = users.map((user) => formatGetUsersObject(user));
return usersDto;
}

// Static bulk upload function to be used in specific cases
// UPDATE THE FILTERS to the current requirements
public async bulkUploadMailchimpProfiles() {
try {
const filterStartDate = '2023-01-01'; // UPDATE
const filterEndDate = '2024-01-01'; // UPDATE
const users = await this.userRepository.find({
where: {
// UPDATE TO ANY FILTERS
createdAt: And(
Raw((alias) => `${alias} >= :filterStartDate`, { filterStartDate: filterStartDate }),
Raw((alias) => `${alias} < :filterEndDate`, { filterEndDate: filterEndDate }),
),
},
relations: {
partnerAccess: { partner: true, therapySession: true },
courseUser: { course: true, sessionUser: { session: true } },
},
});
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}`,
);
} catch (error) {
throw new Error(`Bulk upload mailchimp profiles API call failed: ${error}`);
}
}
}
28 changes: 4 additions & 24 deletions src/utils/serviceUserProfiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,7 @@ describe('Service user profiles', () => {
},
];

await updateServiceUserProfilesTherapy(
partnerAccesses,
SIMPLYBOOK_ACTION_ENUM.NEW_BOOKING,
therapySession.startDateTime,
mockUserEntity.email,
);
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);

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

await updateServiceUserProfilesTherapy(
partnerAccesses,
SIMPLYBOOK_ACTION_ENUM.NEW_BOOKING,
mockAltPartnerAccessEntity.therapySession[1].startDateTime,
mockUserEntity.email,
);
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);

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

await updateServiceUserProfilesTherapy(
partnerAccesses,
SIMPLYBOOK_ACTION_ENUM.UPDATED_BOOKING,
mockAltPartnerAccessEntity.therapySession[1].startDateTime,
mockUserEntity.email,
);
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);

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

await updateServiceUserProfilesTherapy(
partnerAccesses,
SIMPLYBOOK_ACTION_ENUM.CANCELLED_BOOKING,
mockAltPartnerAccessEntity.therapySession[1].startDateTime,
mockUserEntity.email,
);
await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email);

const firstTherapySessionAt =
mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString();
Expand Down
50 changes: 34 additions & 16 deletions src/utils/serviceUserProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,10 @@ export const updateServiceUserProfilesPartnerAccess = async (

export const updateServiceUserProfilesTherapy = async (
partnerAccesses: PartnerAccessEntity[],
therapySessionAction: SIMPLYBOOK_ACTION_ENUM,
therapySessionDate: Date,
email,
) => {
try {
const therapyData = serializeTherapyData(
partnerAccesses,
therapySessionAction,
therapySessionDate,
);
const therapyData = serializeTherapyData(partnerAccesses);
await updateCrispProfile(therapyData.crispSchema, email);
await updateMailchimpProfile(therapyData.mailchimpSchema, email);
} catch (error) {
Expand Down Expand Up @@ -181,6 +175,36 @@ export const createMailchimpCourseMergeField = async (courseName: string) => {
}
};

// Currently only used in bulk upload function, as mailchimp profiles are typically built
// incrementally on sign up and subsequent user actions
export const createCompleteMailchimpUserProfile = (user: UserEntity): ListMemberPartial => {
const userData = serializeUserData(user);
const partnerData = serializePartnerAccessData(user.partnerAccess);
const therapyData = serializeTherapyData(user.partnerAccess);

const courseData = {};
user.courseUser.forEach((courseUser) => {
const courseUserData = serializeCourseData(courseUser);
Object.keys(courseUserData.mailchimpSchema.merge_fields).forEach((key) => {
courseData[key] = courseUserData.mailchimpSchema.merge_fields[key];
});
});

const profileData = {
email_address: user.email,
...userData.mailchimpSchema,

merge_fields: {
SIGNUPD: user.createdAt?.toISOString(),
...userData.mailchimpSchema.merge_fields,
...partnerData.mailchimpSchema.merge_fields,
...therapyData.mailchimpSchema.merge_fields,
...courseData,
},
};
return profileData;
};

export const serializePartnersString = (partnerAccesses: PartnerAccessEntity[]) => {
return partnerAccesses?.map((pa) => pa.partner.name.toLowerCase()).join('; ') || '';
};
Expand All @@ -203,7 +227,7 @@ const serializeUserData = (user: UserEntity) => {
enabled: contactPermission,
},
],
language: signUpLanguage,
language: signUpLanguage || 'en',
merge_fields: { NAME: name },
} as ListMemberPartial;

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

const serializeTherapyData = (
partnerAccesses: PartnerAccessEntity[],
therapySessionAction: SIMPLYBOOK_ACTION_ENUM,
therapySessionDate: Date,
) => {
const serializeTherapyData = (partnerAccesses: PartnerAccessEntity[]) => {
const therapySessions = partnerAccesses
.flatMap((partnerAccess) => partnerAccess.therapySession)
.filter((therapySession) => therapySession.action !== SIMPLYBOOK_ACTION_ENUM.CANCELLED_BOOKING)
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());

const pastTherapySessions = therapySessions.filter(
(therapySession) =>
therapySession.startDateTime !== therapySessionDate &&
therapySession.startDateTime.getTime() < new Date().getTime(),
(therapySession) => therapySession.startDateTime.getTime() < new Date().getTime(),
);
const futureTherapySessions = therapySessions.filter(
(therapySession) => therapySession.startDateTime.getTime() > new Date().getTime(),
Expand Down
14 changes: 2 additions & 12 deletions src/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,7 @@ export class WebhooksService {
},
});

updateServiceUserProfilesTherapy(
[...partnerAccesses],
action,
therapySession.startDateTime,
user.email,
);
updateServiceUserProfilesTherapy([...partnerAccesses], user.email);

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

updateServiceUserProfilesTherapy(
[...partnerAccesses, partnerAccess],
SIMPLYBOOK_ACTION_ENUM.NEW_BOOKING,
therapySession.startDateTime,
user.email,
);
updateServiceUserProfilesTherapy([...partnerAccesses, partnerAccess], user.email);

return therapySession;
} catch (err) {
Expand Down
Loading