diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 079d1940..1720ca33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,11 @@ Chayn team members usually respond within 3 business days. Chayn is open to all kinds of contributions, such as: -- additional software tests -- code of any kind (enhancements, new features, maintenance) -- no-code (documenation, translations) \*see spam policy below for accepted documentation changes. +- additional software tests / test coverage +- dependency updates *check Dependabot pull requests +- code (requested features, bug fixes, quality enhancements, maintenance help) +- accessibility and language support. +- no-code (documentation, translations) \*see spam policy below for accepted documentation changes. # Chayn's Spam Contribution Policy 🚫 diff --git a/package.json b/package.json index 8448b82e..2d40a13c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "dependencies": { "@mailchimp/mailchimp_marketing": "^3.0.80", "@nestjs/axios": "^3.0.2", - "@nestjs/common": "^10.3.6", + "@nestjs/common": "^10.3.10", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.3.6", "@nestjs/platform-express": "^10.3.7", @@ -57,10 +57,10 @@ "typeorm": "^0.3.20" }, "devDependencies": { - "@eslint/js": "^9.0.0", + "@eslint/js": "^9.8.0", "@golevelup/ts-jest": "^0.5.0", "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.1", + "@nestjs/schematics": "^10.1.3", "@nestjs/testing": "^10.3.10", "@types/date-fns": "^2.6.0", "@types/express": "^4.17.21", @@ -72,14 +72,14 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", - "prettier": "^3.2.5", + "prettier": "^3.3.3", "supertest": "^7.0.0", "ts-jest": "^29.2.3", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.16.1" + "typescript-eslint": "^7.17.0" }, "engines": { "node": "20.x", diff --git a/src/api/mailchimp/mailchimp-api.ts b/src/api/mailchimp/mailchimp-api.ts index ee9cc14b..05ad1bae 100644 --- a/src/api/mailchimp/mailchimp-api.ts +++ b/src/api/mailchimp/mailchimp-api.ts @@ -1,8 +1,6 @@ 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, @@ -34,17 +32,18 @@ export const createMailchimpProfile = async ( } }; -export const batchCreateMailchimpProfiles = async (users: UserEntity[]) => { +export const batchCreateMailchimpProfiles = async ( + userProfiles: Partial[], +) => { try { const operations = []; - users.forEach((user) => { - const profileData = createCompleteMailchimpUserProfile(user); + userProfiles.forEach((userProfile, index) => { operations.push({ method: 'POST', path: `/lists/${mailchimpAudienceId}/members`, - operation_id: user.id, - body: JSON.stringify(profileData), + operation_id: index, + body: JSON.stringify(userProfile), }); }); diff --git a/src/event-logger/event-logger.module.ts b/src/event-logger/event-logger.module.ts index f0ff9a34..db1062a3 100644 --- a/src/event-logger/event-logger.module.ts +++ b/src/event-logger/event-logger.module.ts @@ -10,6 +10,7 @@ import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { PartnerAccessService } from 'src/partner-access/partner-access.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { SubscriptionService } from 'src/subscription/subscription.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; @@ -33,6 +34,7 @@ import { EventLoggerService } from './event-logger.service'; providers: [ EventLoggerService, UserService, + ServiceUserProfilesService, SubscriptionUserService, TherapySessionService, PartnerAccessService, diff --git a/src/firebase/firebase.module.ts b/src/firebase/firebase.module.ts index 06ebc191..2fe7ecf2 100644 --- a/src/firebase/firebase.module.ts +++ b/src/firebase/firebase.module.ts @@ -12,6 +12,7 @@ import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { SubscriptionService } from 'src/subscription/subscription.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; @@ -38,6 +39,7 @@ import { FIREBASE, firebaseFactory } from './firebase-factory'; PartnerAccessService, CourseUserService, PartnerService, + ServiceUserProfilesService, SubscriptionUserService, SubscriptionService, TherapySessionService, diff --git a/src/main.ts b/src/main.ts index 3b8e995a..5612a2da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,7 +32,7 @@ async function bootstrap() { const logger = app.get(Logger); app.useLogger(logger); app.useGlobalInterceptors(new LoggingInterceptor()); - app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new ExceptionsFilter()); await app.listen(PORT); console.log(`Listening on localhost:${PORT}, CTRL+C to stop`); diff --git a/src/partner-access/partner-access.module.ts b/src/partner-access/partner-access.module.ts index c7260955..12968efb 100644 --- a/src/partner-access/partner-access.module.ts +++ b/src/partner-access/partner-access.module.ts @@ -12,6 +12,7 @@ import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { SubscriptionService } from 'src/subscription/subscription.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; @@ -42,6 +43,7 @@ import { PartnerAccessService } from './partner-access.service'; UserService, CourseUserService, PartnerService, + ServiceUserProfilesService, SubscriptionUserService, SubscriptionService, TherapySessionService, diff --git a/src/partner-access/partner-access.service.spec.ts b/src/partner-access/partner-access.service.spec.ts index 110741cb..e775c7b0 100644 --- a/src/partner-access/partner-access.service.spec.ts +++ b/src/partner-access/partner-access.service.spec.ts @@ -5,8 +5,8 @@ import { sub } from 'date-fns'; import * as crispApi from 'src/api/crisp/crisp-api'; import * as mailchimpApi from 'src/api/mailchimp/mailchimp-api'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { GetUserDto } from 'src/user/dtos/get-user.dto'; -import * as profileData from 'src/utils/serviceUserProfiles'; import { mockPartnerAccessEntity, mockPartnerAccessEntityBase, @@ -60,6 +60,7 @@ describe('PartnerAccessService', () => { let repo: Repository; let mockPartnerRepository: DeepMocked>; let mockPartnerAccessRepository: DeepMocked>; + let mockServiceUserProfilesService: DeepMocked; beforeEach(async () => { jest.clearAllMocks(); @@ -68,6 +69,7 @@ describe('PartnerAccessService', () => { mockPartnerAccessRepository = createMock>( mockPartnerAccessRepositoryMethods, ); + mockServiceUserProfilesService = createMock(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -80,6 +82,7 @@ describe('PartnerAccessService', () => { provide: getRepositoryToken(PartnerEntity), useValue: mockPartnerRepository, }, + { provide: ServiceUserProfilesService, useValue: mockServiceUserProfilesService }, ], }).compile(); @@ -145,8 +148,6 @@ describe('PartnerAccessService', () => { }); // Mocks that the accesscode already exists jest.spyOn(repo, 'findOne').mockResolvedValueOnce(mockPartnerAccessEntity); - // Observer on the service user profiles method - jest.spyOn(profileData, 'updateServiceUserProfilesPartnerAccess'); const partnerAccess = await service.assignPartnerAccess(mockUserEntity, '123456'); @@ -156,10 +157,9 @@ describe('PartnerAccessService', () => { activatedAt: partnerAccess.activatedAt, }); - expect(profileData.updateServiceUserProfilesPartnerAccess).toHaveBeenCalledWith( - [mockPartnerAccessEntity], - mockUserEntity.email, - ); + expect( + mockServiceUserProfilesService.updateServiceUserProfilesPartnerAccess, + ).toHaveBeenCalledWith([mockPartnerAccessEntity], mockUserEntity.email); }); it('should assign partner access even if crisp profile api fails', async () => { diff --git a/src/partner-access/partner-access.service.ts b/src/partner-access/partner-access.service.ts index dda828e1..fac912e2 100644 --- a/src/partner-access/partner-access.service.ts +++ b/src/partner-access/partner-access.service.ts @@ -5,7 +5,7 @@ import _ from 'lodash'; import { PartnerEntity } from 'src/entities/partner.entity'; import { UserEntity } from 'src/entities/user.entity'; import { Logger } from 'src/logger/logger'; -import { updateServiceUserProfilesPartnerAccess } from 'src/utils/serviceUserProfiles'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { Repository } from 'typeorm'; import { PartnerAccessEntity } from '../entities/partner-access.entity'; import { FEATURES, PartnerAccessCodeStatusEnum } from '../utils/constants'; @@ -29,6 +29,7 @@ export class PartnerAccessService { private partnerAccessRepository: Repository, @InjectRepository(PartnerEntity) private partnerRepository: Repository, + private readonly serviceUserProfilesService: ServiceUserProfilesService, ) {} async createPartnerAccess( @@ -194,7 +195,10 @@ export class PartnerAccessService { }, relations: { partner: true }, }); - updateServiceUserProfilesPartnerAccess(partnerAccesses, user.email); + this.serviceUserProfilesService.updateServiceUserProfilesPartnerAccess( + partnerAccesses, + user.email, + ); } catch (error) { this.logger.error( `Error: Unable to update crisp profile for ${user.email}. Error: ${error.message} `, diff --git a/src/service-user-profiles/service-user-profiles.module.ts b/src/service-user-profiles/service-user-profiles.module.ts new file mode 100644 index 00000000..e82ab563 --- /dev/null +++ b/src/service-user-profiles/service-user-profiles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserEntity } from 'src/entities/user.entity'; +import { UserService } from 'src/user/user.service'; +import { ServiceUserProfilesService } from './service-user-profiles.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserEntity])], + providers: [ServiceUserProfilesService, UserService], +}) +export class ServiceUserProfilesModule {} diff --git a/src/utils/serviceUserProfiles.spec.ts b/src/service-user-profiles/service-user-profiles.service.spec.ts similarity index 83% rename from src/utils/serviceUserProfiles.spec.ts rename to src/service-user-profiles/service-user-profiles.service.spec.ts index b2a0e5ac..da11c049 100644 --- a/src/utils/serviceUserProfiles.spec.ts +++ b/src/service-user-profiles/service-user-profiles.service.spec.ts @@ -1,3 +1,6 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { createCrispProfile, updateCrispProfile, @@ -9,6 +12,7 @@ import { updateMailchimpProfile, } from 'src/api/mailchimp/mailchimp-api'; import { UserEntity } from 'src/entities/user.entity'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { mockAltPartnerAccessEntity, mockCourseUserEntity, @@ -16,34 +20,43 @@ import { mockPartnerEntity, mockUserEntity, } from 'test/utils/mockData'; +import { mockUserRepositoryMethods } from 'test/utils/mockedServices'; +import { Repository } from 'typeorm'; import { EMAIL_REMINDERS_FREQUENCY, SIMPLYBOOK_ACTION_ENUM, mailchimpMarketingPermissionId, -} from './constants'; -import { - createMailchimpCourseMergeField, - createServiceUserProfiles, - serializePartnersString, - serializeUserData, - updateServiceUserEmailAndProfiles, - updateServiceUserProfilesCourse, - updateServiceUserProfilesPartnerAccess, - updateServiceUserProfilesTherapy, - updateServiceUserProfilesUser, -} from './serviceUserProfiles'; +} from '../utils/constants'; jest.mock('src/api/crisp/crisp-api'); jest.mock('src/api/mailchimp/mailchimp-api'); describe('Service user profiles', () => { + let service: ServiceUserProfilesService; + const mockedUserRepository = createMock>(mockUserRepositoryMethods); + beforeEach(async () => { jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ServiceUserProfilesService, + { + provide: getRepositoryToken(UserEntity), + useValue: mockedUserRepository, + }, + ], + }).compile(); + + service = module.get(ServiceUserProfilesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); }); describe('createServiceUserProfiles', () => { it('should create crisp and mailchimp profiles for a public user', async () => { - await createServiceUserProfiles(mockUserEntity); + await service.createServiceUserProfiles(mockUserEntity); expect(createCrispProfile).toHaveBeenCalledWith({ email: mockUserEntity.email, @@ -96,7 +109,11 @@ describe('Service user profiles', () => { }); it('should create crisp and mailchimp profiles for a partner user', async () => { - await createServiceUserProfiles(mockUserEntity, mockPartnerEntity, mockPartnerAccessEntity); + await service.createServiceUserProfiles( + mockUserEntity, + mockPartnerEntity, + mockPartnerAccessEntity, + ); const partnerName = mockPartnerEntity.name.toLowerCase(); const createdAt = mockUserEntity.createdAt.toISOString(); @@ -152,17 +169,23 @@ describe('Service user profiles', () => { it('should not propagate external api call errors', async () => { const mocked = jest.mocked(createCrispProfile); mocked.mockRejectedValue(new Error('Crisp API call failed')); - await expect(createServiceUserProfiles(mockUserEntity)).resolves.not.toThrow(); + await expect(service.createServiceUserProfiles(mockUserEntity)).resolves.not.toThrow(); mocked.mockReset(); }); }); describe('updateServiceUserProfilesUser', () => { it('should update crisp and mailchimp profile user data', async () => { - await updateServiceUserProfilesUser(mockUserEntity, false, mockUserEntity.email); + await service.updateServiceUserProfilesUser( + mockUserEntity, + false, + false, + mockUserEntity.email, + ); const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); + expect(updateCrispProfile).toHaveBeenCalledTimes(1); expect(updateCrispProfile).toHaveBeenCalledWith( { marketing_permission: mockUserEntity.contactPermission, @@ -173,6 +196,7 @@ describe('Service user profiles', () => { mockUserEntity.email, ); + expect(updateMailchimpProfile).toHaveBeenCalledTimes(1); expect(updateMailchimpProfile).toHaveBeenCalledWith( { language: mockUserEntity.signUpLanguage, @@ -202,7 +226,7 @@ describe('Service user profiles', () => { }; const lastActiveAt = mockUserEntity.lastActiveAt.toISOString(); - await updateServiceUserProfilesUser(mockUser, false, mockUser.email); + await service.updateServiceUserProfilesUser(mockUser, false, false, mockUser.email); expect(updateCrispProfile).toHaveBeenCalledWith( { @@ -236,7 +260,12 @@ describe('Service user profiles', () => { }); it('should additionally call crisp base profile update if required', async () => { - await updateServiceUserProfilesUser(mockUserEntity, true, mockUserEntity.email); + await service.updateServiceUserProfilesUser( + mockUserEntity, + true, + false, + mockUserEntity.email, + ); expect(updateCrispProfile).toHaveBeenCalled(); expect(updateMailchimpProfile).toHaveBeenCalled(); @@ -252,11 +281,41 @@ describe('Service user profiles', () => { ); }); + it("should update the user's email in crisp and mailchimp", async () => { + const oldEmail = mockUserEntity.email; + const newEmail = 'newemail@test.com'; + await service.updateServiceUserProfilesUser( + { ...mockUserEntity, email: newEmail }, + true, + true, + oldEmail, + ); + const serialisedMockUserData = service.serializeUserData(mockUserEntity); + expect(updateCrispProfileBase).toHaveBeenCalledWith( + { email: newEmail, person: { locales: ['en'], nickname: 'name' } }, + oldEmail, + ); + expect(updateCrispProfile).toHaveBeenCalledTimes(1); + expect(updateCrispProfile).toHaveBeenCalledWith( + { + email_reminders_frequency: EMAIL_REMINDERS_FREQUENCY.TWO_MONTHS, + last_active_at: mockUserEntity.lastActiveAt.toISOString(), + marketing_permission: true, + service_emails_permission: true, + }, + newEmail, + ); + expect(updateMailchimpProfile).toHaveBeenCalledWith( + { ...serialisedMockUserData.mailchimpSchema, email_address: newEmail }, + oldEmail, + ); + }); + it('should not propagate external api call errors', async () => { const mocked = jest.mocked(updateMailchimpProfile); mocked.mockRejectedValue(new Error('Mailchimp API call failed')); await expect( - updateServiceUserProfilesUser(mockUserEntity, false, mockUserEntity.email), + service.updateServiceUserProfilesUser(mockUserEntity, false, false, mockUserEntity.email), ).resolves.not.toThrow(); mocked.mockReset(); }); @@ -264,7 +323,10 @@ describe('Service user profiles', () => { describe('updateServiceUserProfilesPartnerAccess', () => { it('should update crisp and mailchimp profile partner access data', async () => { - await updateServiceUserProfilesPartnerAccess([mockPartnerAccessEntity], mockUserEntity.email); + await service.updateServiceUserProfilesPartnerAccess( + [mockPartnerAccessEntity], + mockUserEntity.email, + ); const partnerString = mockPartnerAccessEntity.partner.name.toLowerCase(); @@ -302,9 +364,9 @@ describe('Service user profiles', () => { it('should update crisp and mailchimp profile multiple partner accesses data', async () => { const partnerAccesses = [mockPartnerAccessEntity, mockAltPartnerAccessEntity]; - await updateServiceUserProfilesPartnerAccess(partnerAccesses, mockUserEntity.email); + await service.updateServiceUserProfilesPartnerAccess(partnerAccesses, mockUserEntity.email); - const partnerString = serializePartnersString(partnerAccesses); + const partnerString = service.serializePartnersString(partnerAccesses); expect(updateCrispProfileBase).toHaveBeenCalledWith( { @@ -342,7 +404,10 @@ describe('Service user profiles', () => { const mocked = jest.mocked(updateCrispProfile); mocked.mockRejectedValue(new Error('Crisp API call failed')); await expect( - updateServiceUserProfilesPartnerAccess([mockPartnerAccessEntity], mockUserEntity.email), + service.updateServiceUserProfilesPartnerAccess( + [mockPartnerAccessEntity], + mockUserEntity.email, + ), ).resolves.not.toThrow(); mocked.mockReset(); }); @@ -360,7 +425,7 @@ describe('Service user profiles', () => { }, ]; - await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email); + await service.updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email); const firstTherapySessionAt = therapySession.startDateTime.toISOString(); const nextTherapySessionAt = therapySession.startDateTime.toISOString(); @@ -394,7 +459,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, mockUserEntity.email); + await service.updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email); const firstTherapySessionAt = mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString(); @@ -431,7 +496,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, mockUserEntity.email); + await service.updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email); const firstTherapySessionAt = mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString(); @@ -470,7 +535,7 @@ describe('Service user profiles', () => { SIMPLYBOOK_ACTION_ENUM.CANCELLED_BOOKING; const partnerAccesses = [mockPartnerAccessEntity, mockAltPartnerAccessEntity]; - await updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email); + await service.updateServiceUserProfilesTherapy(partnerAccesses, mockUserEntity.email); const firstTherapySessionAt = mockPartnerAccessEntity.therapySession[0].startDateTime.toISOString(); @@ -506,7 +571,7 @@ describe('Service user profiles', () => { const mocked = jest.mocked(updateMailchimpProfile); mocked.mockRejectedValue(new Error('Mailchimp API call failed')); await expect( - updateServiceUserProfilesTherapy( + service.updateServiceUserProfilesTherapy( [mockPartnerAccessEntity, mockAltPartnerAccessEntity], mockUserEntity.email, ), @@ -517,7 +582,7 @@ describe('Service user profiles', () => { describe('updateServiceUserProfilesCourse', () => { it('should update crisp and mailchimp profile course data', async () => { - await updateServiceUserProfilesCourse(mockCourseUserEntity, mockUserEntity.email); + await service.updateServiceUserProfilesCourse(mockCourseUserEntity, mockUserEntity.email); expect(updateCrispProfile).toHaveBeenCalledWith( { @@ -542,7 +607,7 @@ describe('Service user profiles', () => { const mocked = jest.mocked(updateCrispProfile); mocked.mockRejectedValue(new Error('Crisp API call failed')); await expect( - updateServiceUserProfilesCourse(mockCourseUserEntity, mockUserEntity.email), + service.updateServiceUserProfilesCourse(mockCourseUserEntity, mockUserEntity.email), ).resolves.not.toThrow(); mocked.mockReset(); }); @@ -550,7 +615,7 @@ describe('Service user profiles', () => { describe('createMailchimpCourseMergeField', () => { it('should create mailchimp course merge field', async () => { - await createMailchimpCourseMergeField('Full course name'); + await service.createMailchimpCourseMergeField('Full course name'); expect(createMailchimpMergeField).toHaveBeenNthCalledWith( 1, @@ -566,45 +631,4 @@ describe('Service user profiles', () => { ); }); }); - describe('updateServiceUserEmailAndProfiles', () => { - it("should update the user's email in crisp and mailchimp", async () => { - const oldEmail = mockUserEntity.email; - const newEmail = 'newemail@test.com'; - await updateServiceUserEmailAndProfiles({ ...mockUserEntity, email: newEmail }, oldEmail); - const serialisedMockUserData = serializeUserData(mockUserEntity); - expect(updateCrispProfileBase).toHaveBeenCalledWith( - { email: newEmail, person: { locales: ['en'], nickname: 'name' } }, - oldEmail, - ); - expect(updateCrispProfile).toHaveBeenCalledWith( - { ...serialisedMockUserData.crispSchema }, - newEmail, - ); - expect(updateMailchimpProfile).toHaveBeenCalledWith( - { ...serialisedMockUserData.mailchimpSchema, email_address: newEmail }, - oldEmail, - ); - }); - it('should not throw if request to Mailchimp API call fails', async () => { - const mocked = jest.mocked(updateMailchimpProfile); - mocked.mockRejectedValue(new Error('Mailchimp API call failed')); - const oldEmail = mockUserEntity.email; - const newEmail = 'newemail@test.com'; - - await expect( - updateServiceUserEmailAndProfiles({ ...mockUserEntity, email: newEmail }, oldEmail), - ).resolves.not.toThrow(); - mocked.mockReset(); - }); - it('should not throw if request to Crisp API call fails', async () => { - const mocked = jest.mocked(updateCrispProfileBase); - mocked.mockRejectedValue(new Error('Crisp API call failed')); - const oldEmail = mockUserEntity.email; - const newEmail = 'newemail@test.com'; - await expect( - updateServiceUserEmailAndProfiles({ ...mockUserEntity, email: newEmail }, oldEmail), - ).resolves.not.toThrow(); - mocked.mockReset(); - }); - }); }); diff --git a/src/service-user-profiles/service-user-profiles.service.ts b/src/service-user-profiles/service-user-profiles.service.ts new file mode 100644 index 00000000..50d48789 --- /dev/null +++ b/src/service-user-profiles/service-user-profiles.service.ts @@ -0,0 +1,445 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + createCrispProfile, + updateCrispProfile, + updateCrispProfileBase, +} from 'src/api/crisp/crisp-api'; +import { + batchCreateMailchimpProfiles, + createMailchimpMergeField, + createMailchimpProfile, + updateMailchimpProfile, +} from 'src/api/mailchimp/mailchimp-api'; +import { + ListMemberPartial, + MAILCHIMP_MERGE_FIELD_TYPES, +} from 'src/api/mailchimp/mailchimp-api.interfaces'; +import { CourseUserEntity } from 'src/entities/course-user.entity'; +import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; +import { PartnerEntity } from 'src/entities/partner.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { And, Raw, Repository } from 'typeorm'; +import { + PROGRESS_STATUS, + SIMPLYBOOK_ACTION_ENUM, + mailchimpMarketingPermissionId, +} from '../utils/constants'; +import { getAcronym } from '../utils/utils'; + +// Functionality for syncing user profiles for Crisp and Mailchimp communications services. +// User data must be serialized to handle service-specific data structure and different key names +// due to mailchimp field name restrictions allowing only max 10 uppercase characters + +// Note errors are not thrown to prevent the more important calling functions from failing +// Instead log errors which are also captured by rollbar error reporting +const logger = new Logger('ServiceUserProfiles'); + +@Injectable() +export class ServiceUserProfilesService { + constructor(@InjectRepository(UserEntity) private userRepository: Repository) {} + + async createServiceUserProfiles( + user: UserEntity, + partner?: PartnerEntity | null, + partnerAccess?: PartnerAccessEntity | null, + ) { + const { email } = user; + try { + const userData = this.serializeUserData(user); + + const partnerData = this.serializePartnerAccessData( + partnerAccess ? [{ ...partnerAccess, partner }] : [], + ); + + await createCrispProfile({ + email: email, + person: { nickname: user.name, locales: [user.signUpLanguage || 'en'] }, + segments: this.serializeCrispPartnerSegments(partner ? [partner] : []), + }); + + const userSignedUpAt = user.createdAt?.toISOString(); + + await updateCrispProfile( + { + signed_up_at: userSignedUpAt, + ...userData.crispSchema, + ...partnerData.crispSchema, + }, + email, + ); + + const mailchimpMergeFields = { + SIGNUPD: userSignedUpAt, + ...userData.mailchimpSchema.merge_fields, + ...partnerData.mailchimpSchema.merge_fields, + }; + + await createMailchimpProfile({ + email_address: email, + ...userData.mailchimpSchema, + ...partnerData.mailchimpSchema, + merge_fields: mailchimpMergeFields, + }); + + logger.log(`Create user: updated service user profiles. User: ${email}`); + } catch (error) { + logger.error(`Create service user profiles error - ${error}. User: ${email}`); + } + } + + async updateServiceUserProfilesUser( + user: UserEntity, + isCrispBaseUpdateRequired: boolean, + isEmailUpdateRequired: boolean, + existingEmail: string, + ) { + const email = isEmailUpdateRequired ? user.email : existingEmail; + + try { + if (isCrispBaseUpdateRequired) { + // Extra call required to update crisp "base" profile when name or sign up language is changed + await updateCrispProfileBase( + { + ...(isEmailUpdateRequired && { email: email }), + person: { + nickname: user.name, + locales: [user.signUpLanguage || 'en'], + }, + }, + existingEmail, + ); + } + + const userData = this.serializeUserData(user); + await updateCrispProfile(userData.crispSchema, email); + await updateMailchimpProfile( + { + ...userData.mailchimpSchema, + ...(isEmailUpdateRequired && { email_address: email }), + }, + existingEmail, + ); + logger.log(`Updated service user profiles user. Email: ${email}`); + } catch (error) { + if (error.toString() === 'Error: Not found') { + // mailchimp account not found, create one + const userWithRelations = await this.userRepository.findOne({ + where: { id: user.id }, + relations: { + partnerAccess: { partner: true, therapySession: true }, + courseUser: { course: true, sessionUser: { session: true } }, + }, + }); + this.createCompleteMailchimpUserProfile(userWithRelations); + logger.log(`Created and updated service user profiles user. Email: ${email}`); + } + logger.error(`Update service user profiles user error - ${error}`); + } + } + + async updateServiceUserProfilesPartnerAccess( + partnerAccesses: PartnerAccessEntity[], + email: string, + ) { + try { + const partners = partnerAccesses.map((pa) => pa.partner); + await updateCrispProfileBase( + { + segments: this.serializeCrispPartnerSegments(partners), + }, + email, + ); + + const partnerAccessData = this.serializePartnerAccessData(partnerAccesses); + await updateCrispProfile(partnerAccessData.crispSchema, email); + await updateMailchimpProfile(partnerAccessData.mailchimpSchema, email); + } catch (error) { + logger.error(`Update service user profiles partner access error - ${error}`); + } + } + + async updateServiceUserProfilesTherapy(partnerAccesses: PartnerAccessEntity[], email) { + try { + const therapyData = this.serializeTherapyData(partnerAccesses); + await updateCrispProfile(therapyData.crispSchema, email); + await updateMailchimpProfile(therapyData.mailchimpSchema, email); + } catch (error) { + logger.error(`Update service user profiles therapy error - ${error}`); + } + } + + async updateServiceUserProfilesCourse(courseUser: CourseUserEntity, email: string) { + try { + const courseData = this.serializeCourseData(courseUser); + await updateCrispProfile(courseData.crispSchema, email); + await updateMailchimpProfile(courseData.mailchimpSchema, email); + } catch (error) { + logger.error(`Update service user profiles course error - ${error}`); + } + } + + // Merge fields (custom fields) in mailchimp must be created before they are used + // This function creates 2 new mailchimp merge fields for a new course + async createMailchimpCourseMergeField(courseName: string) { + try { + const courseAcronym = getAcronym(courseName); + const courseMergeFieldName = `Course ${courseAcronym} Status`; + const courseMergeFieldTag = `C_${courseAcronym}`; + const courseSessionsMergeFieldName = `Course ${courseAcronym} Sessions`; + const courseSessionsMergeFieldTag = `C_${courseAcronym}_S`; + + await createMailchimpMergeField( + courseMergeFieldName, + courseMergeFieldTag, + MAILCHIMP_MERGE_FIELD_TYPES.TEXT, + ); + await createMailchimpMergeField( + courseSessionsMergeFieldName, + courseSessionsMergeFieldTag, + MAILCHIMP_MERGE_FIELD_TYPES.TEXT, + ); + } catch (error) { + logger.error(`Create mailchimp course merge fields error - ${error}`); + } + } + + // Currently only used in bulk upload function, as mailchimp profiles are typically built + // incrementally on sign up and subsequent user actions + createCompleteMailchimpUserProfile(user: UserEntity): ListMemberPartial { + const userData = this.serializeUserData(user); + const partnerData = this.serializePartnerAccessData(user.partnerAccess); + const therapyData = this.serializeTherapyData(user.partnerAccess); + + const courseData = {}; + user.courseUser.forEach((courseUser) => { + const courseUserData = this.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; + } + + // Static bulk upload function to be used in specific cases e.g. bug prevented a subset of new users from being created + // Currently no endpoint for this function + // 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 mailchimpUserProfiles = users.map((user) => + this.createCompleteMailchimpUserProfile(user), + ); + + await batchCreateMailchimpProfiles(mailchimpUserProfiles); + logger.log( + `Created batch mailchimp profiles for ${users.length} users, created before ${filterStartDate}`, + ); + } catch (error) { + throw new Error(`Bulk upload mailchimp profiles API call failed: ${error}`); + } + } + + serializePartnersString(partnerAccesses: PartnerAccessEntity[]) { + const partnersNames = partnerAccesses?.map((pa) => pa.partner.name.toLowerCase()); + const partnersString = partnersNames ? [...new Set(partnersNames)].join('; ') : ''; + return partnersString; + } + + serializeCrispPartnerSegments(partners: PartnerEntity[]) { + if (!partners.length) return ['public']; + return partners.map((p) => p.name.toLowerCase()); + } + + serializeUserData(user: UserEntity) { + const { + name, + signUpLanguage, + contactPermission, + serviceEmailsPermission, + lastActiveAt, + emailRemindersFrequency, + } = user; + const lastActiveAtString = lastActiveAt?.toISOString() || ''; + + const crispSchema = { + marketing_permission: contactPermission, + service_emails_permission: serviceEmailsPermission, + last_active_at: lastActiveAtString, + email_reminders_frequency: emailRemindersFrequency, + // Name and language handled on base level profile for crisp + }; + + const mailchimpSchema = { + status: serviceEmailsPermission ? 'subscribed' : 'unsubscribed', + marketing_permissions: [ + { + marketing_permission_id: mailchimpMarketingPermissionId, + text: 'Email', + enabled: contactPermission, + }, + ], + language: signUpLanguage || 'en', + merge_fields: { + NAME: name, + LACTIVED: lastActiveAtString, + REMINDFREQ: emailRemindersFrequency, + }, + } as ListMemberPartial; + + return { crispSchema, mailchimpSchema }; + } + + serializePartnerAccessData(partnerAccesses: PartnerAccessEntity[]) { + const publicUser = !partnerAccesses || !partnerAccesses[0]?.id; + + const data = publicUser + ? { + partners: '', + featureLiveChat: true, + featureTherapy: false, + therapySessionsRemaining: 0, + therapySessionsRedeemed: 0, + } + : { + partners: this.serializePartnersString(partnerAccesses), + featureLiveChat: !!partnerAccesses.find((pa) => pa.featureLiveChat) || true, + featureTherapy: !!partnerAccesses.find((pa) => pa.featureTherapy), + therapySessionsRemaining: partnerAccesses + .map((pa) => pa.therapySessionsRemaining) + .reduce((a, b) => a + b, 0), + therapySessionsRedeemed: partnerAccesses + .map((pa) => pa.therapySessionsRedeemed) + .reduce((a, b) => a + b, 0), + }; + + const crispSchema = { + partners: data.partners, + feature_live_chat: data.featureLiveChat, + feature_therapy: data.featureTherapy, + therapy_sessions_remaining: data.therapySessionsRemaining, + therapy_sessions_redeemed: data.therapySessionsRedeemed, + }; + + const mailchimpSchema = { + merge_fields: { + PARTNERS: data.partners, + FEATCHAT: String(data.featureLiveChat), + FEATTHER: String(data.featureTherapy), + THERREMAIN: data.therapySessionsRemaining, + THERREDEEM: data.therapySessionsRedeemed, + }, + } as ListMemberPartial; + + return { crispSchema, mailchimpSchema }; + } + + 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.getTime() < new Date().getTime(), + ); + const futureTherapySessions = therapySessions.filter( + (therapySession) => therapySession.startDateTime.getTime() > new Date().getTime(), + ); + + const firstTherapySessionAt = therapySessions?.at(0)?.startDateTime.toISOString() || ''; + + const lastTherapySessionAt = pastTherapySessions?.at(-1)?.startDateTime.toISOString() || ''; + + const nextTherapySessionAt = futureTherapySessions?.at(0)?.startDateTime.toISOString() || ''; + + const data = { + therapySessionsRemaining: partnerAccesses.reduce( + (sum, partnerAccess) => sum + partnerAccess.therapySessionsRemaining, + 0, + ), + therapySessionsRedeemed: partnerAccesses.reduce( + (sum, partnerAccess) => sum + partnerAccess.therapySessionsRedeemed, + 0, + ), + }; + + const crispSchema = { + therapy_sessions_remaining: data.therapySessionsRemaining, + therapy_sessions_redeemed: data.therapySessionsRedeemed, + therapy_session_first_at: firstTherapySessionAt, + therapy_session_next_at: nextTherapySessionAt, + therapy_session_last_at: lastTherapySessionAt, + }; + + const mailchimpSchema = { + merge_fields: { + THERREMAIN: data.therapySessionsRemaining, + THERREDEEM: data.therapySessionsRedeemed, + THERFIRSAT: firstTherapySessionAt, + THERNEXTAT: nextTherapySessionAt, + THERLASTAT: lastTherapySessionAt, + }, + }; + + return { crispSchema, mailchimpSchema }; + } + + serializeCourseData(courseUser: CourseUserEntity) { + const courseAcronymLowercase = getAcronym(courseUser.course.name).toLowerCase(); + const courseAcronymUppercase = getAcronym(courseUser.course.name); + + const data = { + course: courseUser.completed ? PROGRESS_STATUS.COMPLETED : PROGRESS_STATUS.STARTED, + sessions: courseUser.sessionUser + .map( + (sessionUser) => + `${getAcronym(sessionUser.session.name)}:${sessionUser.completed ? 'C' : 'S'}`, + ) + .join('; '), + }; + + const crispSchema = { + [`course_${courseAcronymLowercase}`]: data.course, + [`course_${courseAcronymLowercase}_sessions`]: data.sessions, + }; + + const mailchimpSchema = { + merge_fields: { + [`C_${courseAcronymUppercase}`]: data.course, + [`C_${courseAcronymUppercase}_S`]: data.sessions, + }, + } as ListMemberPartial; + + return { crispSchema, mailchimpSchema }; + } +} diff --git a/src/session-feedback/session-feedback.module.ts b/src/session-feedback/session-feedback.module.ts index c69d6b65..6bc114be 100644 --- a/src/session-feedback/session-feedback.module.ts +++ b/src/session-feedback/session-feedback.module.ts @@ -11,6 +11,7 @@ import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { PartnerAccessService } from 'src/partner-access/partner-access.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SessionService } from 'src/session/session.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { SubscriptionService } from 'src/subscription/subscription.service'; @@ -37,6 +38,7 @@ import { SessionFeedbackService } from './session-feedback.service'; SessionFeedbackService, SessionService, UserService, + ServiceUserProfilesService, SubscriptionUserService, SubscriptionService, PartnerAccessService, diff --git a/src/session-user/session-user.module.ts b/src/session-user/session-user.module.ts index 19f4e872..7d5f8d62 100644 --- a/src/session-user/session-user.module.ts +++ b/src/session-user/session-user.module.ts @@ -14,6 +14,7 @@ import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { SubscriptionService } from 'src/subscription/subscription.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; @@ -50,6 +51,7 @@ import { SessionUserService } from './session-user.service'; PartnerAccessService, CourseService, PartnerService, + ServiceUserProfilesService, SubscriptionUserService, TherapySessionService, SubscriptionService, diff --git a/src/session-user/session-user.service.ts b/src/session-user/session-user.service.ts index 397d7237..2c5ae6cc 100644 --- a/src/session-user/session-user.service.ts +++ b/src/session-user/session-user.service.ts @@ -2,17 +2,15 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { UserEntity } from 'src/entities/user.entity'; -import { updateServiceUserProfilesCourse } from 'src/utils/serviceUserProfiles'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { Repository } from 'typeorm'; import { CourseUserService } from '../course-user/course-user.service'; -import { CourseService } from '../course/course.service'; import { CourseUserEntity } from '../entities/course-user.entity'; import { CourseEntity } from '../entities/course.entity'; import { SessionUserEntity } from '../entities/session-user.entity'; import { Logger } from '../logger/logger'; import { SessionService } from '../session/session.service'; import { GetUserDto } from '../user/dtos/get-user.dto'; -import { UserService } from '../user/user.service'; import { STORYBLOK_STORY_STATUS_ENUM } from '../utils/constants'; import { formatCourseUserObject, formatCourseUserObjects } from '../utils/serialize'; import { SessionUserDto } from './dtos/session-user.dto'; @@ -29,9 +27,8 @@ export class SessionUserService { private courseRepository: Repository, @InjectRepository(UserEntity) private userRepository: Repository, private readonly courseUserService: CourseUserService, - private readonly userService: UserService, private readonly sessionService: SessionService, - private readonly courseService: CourseService, + private serviceUserProfilesService: ServiceUserProfilesService, ) {} private async checkCourseIsComplete( @@ -129,7 +126,7 @@ export class SessionUserService { courseId, }); - updateServiceUserProfilesCourse(updatedCourseUser, user.email); + this.serviceUserProfilesService.updateServiceUserProfilesCourse(updatedCourseUser, user.email); return formatCourseUserObject(updatedCourseUser); } @@ -203,7 +200,7 @@ export class SessionUserService { courseUser.course = course; const formattedResponse = formatCourseUserObjects([courseUser])[0]; - updateServiceUserProfilesCourse(courseUser, user.email); + this.serviceUserProfilesService.updateServiceUserProfilesCourse(courseUser, user.email); return formattedResponse; } diff --git a/src/subscription-user/subscription-user.module.ts b/src/subscription-user/subscription-user.module.ts index 9a85f149..40590be3 100644 --- a/src/subscription-user/subscription-user.module.ts +++ b/src/subscription-user/subscription-user.module.ts @@ -9,6 +9,7 @@ import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; import { ZapierWebhookClient } from '../api/zapier/zapier-webhook-client'; import { FirebaseModule } from '../firebase/firebase.module'; @@ -37,6 +38,7 @@ import { SubscriptionUserService } from './subscription-user.service'; SubscriptionService, UserService, PartnerAccessService, + ServiceUserProfilesService, ZapierWebhookClient, PartnerService, TherapySessionService, diff --git a/src/therapy-session/therapy-session.service.ts b/src/therapy-session/therapy-session.service.ts index bebdd738..8c6f3022 100644 --- a/src/therapy-session/therapy-session.service.ts +++ b/src/therapy-session/therapy-session.service.ts @@ -36,7 +36,7 @@ export class TherapySessionService { }); await this.slackMessageClient.sendMessageToTherapySlackChannel( - `User has requested to be deleted - please delete user with emails ${userEmail + emails.join(', ')} from Simplybook, Crisp and from Mailchimp`, + `User has been deleted from bloom - please remove the accounts associated with ${userEmail + emails.join(', ')} from Simplybook, Crisp and from Mailchimp`, ); // redact email from therapy sessions diff --git a/src/user/dtos/admin-update-user.dto.ts b/src/user/dtos/admin-update-user.dto.ts new file mode 100644 index 00000000..f5e7fee6 --- /dev/null +++ b/src/user/dtos/admin-update-user.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsDate, IsEmail, IsOptional, IsString } from 'class-validator'; +import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; + +export class AdminUpdateUserDto { + @IsString() + @IsOptional() + @ApiProperty({ type: String }) + name: string; + + @IsBoolean() + @IsOptional() + @ApiProperty({ type: Boolean }) + contactPermission: boolean; + + @IsBoolean() + @IsOptional() + @ApiProperty({ type: Boolean }) + serviceEmailsPermission: boolean; + + @IsOptional() + @IsString() + @ApiProperty({ type: String }) + emailRemindersFrequency: EMAIL_REMINDERS_FREQUENCY; + + @IsString() + @IsOptional() + @ApiProperty({ type: String }) + signUpLanguage: string; + + @IsDate() + @IsOptional() + @ApiProperty({ type: 'date' }) + lastActiveAt: Date; + + @IsEmail({}) + @IsOptional() + @ApiProperty({ type: 'email' }) + email: string; + + @IsBoolean({}) + @IsOptional() + @ApiProperty({ type: 'boolean' }) + isSuperAdmin: boolean; +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 9683964d..8d162b1e 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -17,6 +17,7 @@ import { SuperAdminAuthGuard } from 'src/partner-admin/super-admin-auth.guard'; import { formatUserObject } from 'src/utils/serialize'; import { FirebaseAuthGuard } from '../firebase/firebase-auth.guard'; import { ControllerDecorator } from '../utils/controller.decorator'; +import { AdminUpdateUserDto } from './dtos/admin-update-user.dto'; import { CreateUserDto } from './dtos/create-user.dto'; import { GetUserDto } from './dtos/get-user.dto'; import { UpdateUserDto } from './dtos/update-user.dto'; @@ -84,14 +85,15 @@ export class UserController { @Patch() @UseGuards(FirebaseAuthGuard) async updateUser(@Body() updateUserDto: UpdateUserDto, @Req() req: Request) { + console.log('>>>>', updateUserDto); return await this.userService.updateUser(updateUserDto, req['user'].user.id); } @ApiBearerAuth() @Patch('/admin/:id') @UseGuards(SuperAdminAuthGuard) - async adminUpdateUser(@Param() { id }, @Body() updateUserDto: UpdateUserDto) { - return await this.userService.updateUser(updateUserDto, id); + async adminUpdateUser(@Param() { id }, @Body() adminUpdateUserDto: AdminUpdateUserDto) { + return await this.userService.adminUpdateUser(adminUpdateUserDto, id); } @ApiBearerAuth() @@ -104,12 +106,4 @@ export class UserController { const users = await this.userService.getUsers(userQuery, include || [], fields, limit); return users.map((u) => formatUserObject(u)); } - - // Use only if users have not been added to mailchimp due to e.g. an ongoing bug - @ApiBearerAuth() - @Post('/bulk-mailchimp-upload') - @UseGuards(SuperAdminAuthGuard) - async bulkUploadMailchimpProfiles() { - return await this.userService.bulkUploadMailchimpProfiles(); - } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index cff8f666..1f1e5ab9 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -9,6 +9,7 @@ import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { SubscriptionService } from 'src/subscription/subscription.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; @@ -36,6 +37,7 @@ import { UserService } from './user.service'; UserService, AuthService, PartnerAccessService, + ServiceUserProfilesService, SubscriptionService, SubscriptionUserService, TherapySessionService, diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 803c065a..ed1479b6 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -7,6 +7,7 @@ import { createCrispProfile, updateCrispProfile } from 'src/api/crisp/crisp-api' import { createMailchimpProfile, updateMailchimpProfile } from 'src/api/mailchimp/mailchimp-api'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; import { EMAIL_REMINDERS_FREQUENCY, PartnerAccessCodeStatusEnum } from 'src/utils/constants'; @@ -29,6 +30,7 @@ import { createQueryBuilderMock } from '../../test/utils/mockUtils'; import { AuthService } from '../auth/auth.service'; import { UserEntity } from '../entities/user.entity'; import { PartnerAccessService } from '../partner-access/partner-access.service'; +import { AdminUpdateUserDto } from './dtos/admin-update-user.dto'; import { CreateUserDto } from './dtos/create-user.dto'; import { UpdateUserDto } from './dtos/update-user.dto'; import { UserService } from './user.service'; @@ -99,6 +101,7 @@ describe('UserService', () => { }, { provide: SubscriptionUserService, useValue: mockSubscriptionUserService }, { provide: TherapySessionService, useValue: mockTherapySessionService }, + ServiceUserProfilesService, ], }).compile(); @@ -540,4 +543,19 @@ describe('UserService', () => { expect(users).toEqual([{ ...mockUserEntity, email: 'a@b.com' }]); }); }); + + describe('adminUpdateUser', () => { + it("should update user's superAdmin status", async () => { + const user = mockUserEntity; + const userSaveSpy = jest.spyOn(repo, 'save').mockImplementationOnce(async () => { + return user; + }); + const updatedUser = await service.adminUpdateUser( + { isSuperAdmin: true } as AdminUpdateUserDto, + user.id, + ); + expect(updatedUser).toHaveProperty('isSuperAdmin', true); + expect(userSaveSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index cffd05f2..fc318eb6 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,27 +1,23 @@ 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'; import { IFirebaseUser } from 'src/firebase/firebase-user.interface'; import { Logger } from 'src/logger/logger'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; import { SIGNUP_TYPE } from 'src/utils/constants'; import { FIREBASE_ERRORS } from 'src/utils/errors'; import { FIREBASE_EVENTS, USER_SERVICE_EVENTS } from 'src/utils/logs'; -import { - createServiceUserProfiles, - updateServiceUserEmailAndProfiles, - updateServiceUserProfilesUser, -} from 'src/utils/serviceUserProfiles'; -import { And, ILike, IsNull, Not, Raw, Repository } from 'typeorm'; +import { ILike, IsNull, Not, 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'; +import { basePartnerAccess, PartnerAccessService } from '../partner-access/partner-access.service'; import { formatUserObject } from '../utils/serialize'; import { generateRandomString } from '../utils/utils'; +import { AdminUpdateUserDto } from './dtos/admin-update-user.dto'; import { CreateUserDto } from './dtos/create-user.dto'; import { GetUserDto } from './dtos/get-user.dto'; import { UpdateUserDto } from './dtos/update-user.dto'; @@ -41,6 +37,7 @@ export class UserService { private readonly subscriptionUserService: SubscriptionUserService, private readonly therapySessionService: TherapySessionService, private readonly partnerAccessService: PartnerAccessService, + private readonly serviceUserProfilesService: ServiceUserProfilesService, ) {} public async createUser(createUserDto: CreateUserDto): Promise { @@ -94,7 +91,7 @@ export class UserService { this.logger.log(`Create user: created public user in db. User: ${email}`); } - await createServiceUserProfiles(user, partner, partnerAccess); + await this.serviceUserProfilesService.createServiceUserProfiles(user, partner, partnerAccess); const userDto = formatUserObject({ ...user, @@ -225,17 +222,52 @@ export class UserService { fields: Object.keys(updateUserDto), }); - if (updateUserDto.email && user.email !== updateUserDto.email) { - updateServiceUserEmailAndProfiles(newUserData, user.email); + const isEmailUpdateRequired = updateUserDto.email && user.email !== updateUserDto.email; + + if ( + Object.keys(updateUserDto).length === 1 && + !!updateUserDto.lastActiveAt && + updateUserDto.lastActiveAt.getDate() === user.lastActiveAt.getDate() + ) { + // Do nothing, prevent unnecessay updates to service profiles when last active date is same date } else { const isCrispBaseUpdateRequired = - user.signUpLanguage !== updateUserDto.signUpLanguage && user.name !== updateUserDto.name; - updateServiceUserProfilesUser(newUserData, isCrispBaseUpdateRequired, user.email); + isEmailUpdateRequired || + user.signUpLanguage !== updateUserDto.signUpLanguage || + user.name !== updateUserDto.name; + this.serviceUserProfilesService.updateServiceUserProfilesUser( + newUserData, + isCrispBaseUpdateRequired, + isEmailUpdateRequired, + user.email, + ); } return updatedUser; } + public async adminUpdateUser(updateUserDto: Partial, userId: string) { + const { isSuperAdmin, ...updateUserDtoWithoutSuperAdmin } = updateUserDto; + + await this.updateUser(updateUserDtoWithoutSuperAdmin, userId); + + if (typeof isSuperAdmin !== 'undefined') { + const user = await this.userRepository.findOneBy({ id: userId }); + if (user.isSuperAdmin !== isSuperAdmin) { + const updatedUser = await this.userRepository.save({ + ...user, + isSuperAdmin, + }); + this.logger.log({ + event: USER_SERVICE_EVENTS.USER_UPDATED, + userId: user.id, + fields: ['isSuperAdmin'], + }); + return updatedUser; + } + } + } + public async deleteCypressTestUsers(clean = false): Promise { let deletedUsers: UserEntity[] = []; try { @@ -324,34 +356,4 @@ export class UserService { }); return users; } - - // 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); - - 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}`); - } - } } diff --git a/src/utils/serviceUserProfiles.ts b/src/utils/serviceUserProfiles.ts deleted file mode 100644 index 47b00eec..00000000 --- a/src/utils/serviceUserProfiles.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { - createCrispProfile, - updateCrispProfile, - updateCrispProfileBase, -} from 'src/api/crisp/crisp-api'; -import { - createMailchimpMergeField, - createMailchimpProfile, - updateMailchimpProfile, -} from 'src/api/mailchimp/mailchimp-api'; -import { - ListMemberPartial, - MAILCHIMP_MERGE_FIELD_TYPES, -} from 'src/api/mailchimp/mailchimp-api.interfaces'; -import { CourseUserEntity } from 'src/entities/course-user.entity'; -import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; -import { PartnerEntity } from 'src/entities/partner.entity'; -import { UserEntity } from 'src/entities/user.entity'; -import { - PROGRESS_STATUS, - SIMPLYBOOK_ACTION_ENUM, - mailchimpMarketingPermissionId, -} from './constants'; -import { getAcronym } from './utils'; - -// Functionality for syncing user profiles for Crisp and Mailchimp communications services. -// User data must be serialized to handle service-specific data structure and different key names -// due to mailchimp field name restrictions allowing only max 10 uppercase characters - -// Note errors are not thrown to prevent the more important calling functions from failing -// Instead log errors which are also captured by rollbar error reporting -const logger = new Logger('ServiceUserProfiles'); - -export const createServiceUserProfiles = async ( - user: UserEntity, - partner?: PartnerEntity | null, - partnerAccess?: PartnerAccessEntity | null, -) => { - const { email } = user; - try { - const userData = serializeUserData(user); - - const partnerData = serializePartnerAccessData( - partnerAccess ? [{ ...partnerAccess, partner }] : [], - ); - - await createCrispProfile({ - email: email, - person: { nickname: user.name, locales: [user.signUpLanguage || 'en'] }, - segments: serializeCrispPartnerSegments(partner ? [partner] : []), - }); - - const userSignedUpAt = user.createdAt?.toISOString(); - - await updateCrispProfile( - { - signed_up_at: userSignedUpAt, - ...userData.crispSchema, - ...partnerData.crispSchema, - }, - email, - ); - - const mailchimpMergeFields = { - SIGNUPD: userSignedUpAt, - ...userData.mailchimpSchema.merge_fields, - ...partnerData.mailchimpSchema.merge_fields, - }; - - await createMailchimpProfile({ - email_address: email, - ...userData.mailchimpSchema, - ...partnerData.mailchimpSchema, - merge_fields: mailchimpMergeFields, - }); - - logger.log(`Create user: updated service user profiles. User: ${email}`); - } catch (error) { - logger.error(`Create service user profiles error - ${error}. User: ${email}`); - } -}; - -export const updateServiceUserProfilesUser = async ( - user: UserEntity, - isCrispBaseUpdateRequired: boolean, - email: string, -) => { - try { - if (isCrispBaseUpdateRequired) { - // Extra call required to update crisp "base" profile when name or sign up language is changed - await updateCrispProfileBase( - { - person: { - nickname: user.name, - locales: [user.signUpLanguage || 'en'], - }, - }, - email, - ); - } - const userData = serializeUserData(user); - await updateCrispProfile(userData.crispSchema, email); - await updateMailchimpProfile(userData.mailchimpSchema, email); - } catch (error) { - logger.error(`Update service user profiles user error - ${error}`); - } -}; - -export const updateServiceUserEmailAndProfiles = async (user: UserEntity, email: string) => { - try { - await updateCrispProfileBase( - { - email: user.email, - person: { - nickname: user.name, - locales: [user.signUpLanguage || 'en'], - }, - }, - email, - ); - logger.log({ event: 'UPDATE_CRISP_PROFILE_BASE', userId: user.id }); - const userData = serializeUserData(user); - await updateCrispProfile(userData.crispSchema, user.email); - logger.log({ event: 'UPDATE_CRISP_PROFILE', userId: user.id }); - await updateMailchimpProfile({ ...userData.mailchimpSchema, email_address: user.email }, email); - logger.log({ event: 'UPDATE_MAILCHIMP_PROFILE', userId: user.id }); - } catch (error) { - logger.error(`Update service user profiles user error - ${error}`); - } -}; - -export const updateServiceUserProfilesPartnerAccess = async ( - partnerAccesses: PartnerAccessEntity[], - email: string, -) => { - try { - const partners = partnerAccesses.map((pa) => pa.partner); - await updateCrispProfileBase( - { - segments: serializeCrispPartnerSegments(partners), - }, - email, - ); - - const partnerAccessData = serializePartnerAccessData(partnerAccesses); - await updateCrispProfile(partnerAccessData.crispSchema, email); - await updateMailchimpProfile(partnerAccessData.mailchimpSchema, email); - } catch (error) { - logger.error(`Update service user profiles partner access error - ${error}`); - } -}; - -export const updateServiceUserProfilesTherapy = async ( - partnerAccesses: PartnerAccessEntity[], - email, -) => { - try { - const therapyData = serializeTherapyData(partnerAccesses); - await updateCrispProfile(therapyData.crispSchema, email); - await updateMailchimpProfile(therapyData.mailchimpSchema, email); - } catch (error) { - logger.error(`Update service user profiles therapy error - ${error}`); - } -}; - -export const updateServiceUserProfilesCourse = async ( - courseUser: CourseUserEntity, - email: string, -) => { - try { - const courseData = serializeCourseData(courseUser); - await updateCrispProfile(courseData.crispSchema, email); - await updateMailchimpProfile(courseData.mailchimpSchema, email); - } catch (error) { - logger.error(`Update service user profiles course error - ${error}`); - } -}; - -// Merge fields (custom fields) in mailchimp must be created before they are used -// This function creates 2 new mailchimp merge fields for a new course -export const createMailchimpCourseMergeField = async (courseName: string) => { - try { - const courseAcronym = getAcronym(courseName); - const courseMergeFieldName = `Course ${courseAcronym} Status`; - const courseMergeFieldTag = `C_${courseAcronym}`; - const courseSessionsMergeFieldName = `Course ${courseAcronym} Sessions`; - const courseSessionsMergeFieldTag = `C_${courseAcronym}_S`; - - await createMailchimpMergeField( - courseMergeFieldName, - courseMergeFieldTag, - MAILCHIMP_MERGE_FIELD_TYPES.TEXT, - ); - await createMailchimpMergeField( - courseSessionsMergeFieldName, - courseSessionsMergeFieldTag, - MAILCHIMP_MERGE_FIELD_TYPES.TEXT, - ); - } catch (error) { - logger.error(`Create mailchimp course merge fields error - ${error}`); - } -}; - -// 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[]) => { - const partnersNames = partnerAccesses?.map((pa) => pa.partner.name.toLowerCase()); - const partnersString = partnersNames ? [...new Set(partnersNames)].join('; ') : ''; - return partnersString; -}; - -const serializeCrispPartnerSegments = (partners: PartnerEntity[]) => { - if (!partners.length) return ['public']; - return partners.map((p) => p.name.toLowerCase()); -}; - -export const serializeUserData = (user: UserEntity) => { - const { - name, - signUpLanguage, - contactPermission, - serviceEmailsPermission, - lastActiveAt, - emailRemindersFrequency, - } = user; - const lastActiveAtString = lastActiveAt?.toISOString() || ''; - - const crispSchema = { - marketing_permission: contactPermission, - service_emails_permission: serviceEmailsPermission, - last_active_at: lastActiveAtString, - email_reminders_frequency: emailRemindersFrequency, - // Name and language handled on base level profile for crisp - }; - - const mailchimpSchema = { - status: serviceEmailsPermission ? 'subscribed' : 'unsubscribed', - marketing_permissions: [ - { - marketing_permission_id: mailchimpMarketingPermissionId, - text: 'Email', - enabled: contactPermission, - }, - ], - language: signUpLanguage || 'en', - merge_fields: { - NAME: name, - LACTIVED: lastActiveAtString, - REMINDFREQ: emailRemindersFrequency, - }, - } as ListMemberPartial; - - return { crispSchema, mailchimpSchema }; -}; - -const serializePartnerAccessData = (partnerAccesses: PartnerAccessEntity[]) => { - const publicUser = !partnerAccesses || !partnerAccesses[0]?.id; - - const data = publicUser - ? { - partners: '', - featureLiveChat: true, - featureTherapy: false, - therapySessionsRemaining: 0, - therapySessionsRedeemed: 0, - } - : { - partners: serializePartnersString(partnerAccesses), - featureLiveChat: !!partnerAccesses.find((pa) => pa.featureLiveChat) || true, - featureTherapy: !!partnerAccesses.find((pa) => pa.featureTherapy), - therapySessionsRemaining: partnerAccesses - .map((pa) => pa.therapySessionsRemaining) - .reduce((a, b) => a + b, 0), - therapySessionsRedeemed: partnerAccesses - .map((pa) => pa.therapySessionsRedeemed) - .reduce((a, b) => a + b, 0), - }; - - const crispSchema = { - partners: data.partners, - feature_live_chat: data.featureLiveChat, - feature_therapy: data.featureTherapy, - therapy_sessions_remaining: data.therapySessionsRemaining, - therapy_sessions_redeemed: data.therapySessionsRedeemed, - }; - - const mailchimpSchema = { - merge_fields: { - PARTNERS: data.partners, - FEATCHAT: String(data.featureLiveChat), - FEATTHER: String(data.featureTherapy), - THERREMAIN: data.therapySessionsRemaining, - THERREDEEM: data.therapySessionsRedeemed, - }, - } as ListMemberPartial; - - return { crispSchema, mailchimpSchema }; -}; - -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.getTime() < new Date().getTime(), - ); - const futureTherapySessions = therapySessions.filter( - (therapySession) => therapySession.startDateTime.getTime() > new Date().getTime(), - ); - - const firstTherapySessionAt = therapySessions?.at(0)?.startDateTime.toISOString() || ''; - - const lastTherapySessionAt = pastTherapySessions?.at(-1)?.startDateTime.toISOString() || ''; - - const nextTherapySessionAt = futureTherapySessions?.at(0)?.startDateTime.toISOString() || ''; - - const data = { - therapySessionsRemaining: partnerAccesses.reduce( - (sum, partnerAccess) => sum + partnerAccess.therapySessionsRemaining, - 0, - ), - therapySessionsRedeemed: partnerAccesses.reduce( - (sum, partnerAccess) => sum + partnerAccess.therapySessionsRedeemed, - 0, - ), - }; - - const crispSchema = { - therapy_sessions_remaining: data.therapySessionsRemaining, - therapy_sessions_redeemed: data.therapySessionsRedeemed, - therapy_session_first_at: firstTherapySessionAt, - therapy_session_next_at: nextTherapySessionAt, - therapy_session_last_at: lastTherapySessionAt, - }; - - const mailchimpSchema = { - merge_fields: { - THERREMAIN: data.therapySessionsRemaining, - THERREDEEM: data.therapySessionsRedeemed, - THERFIRSAT: firstTherapySessionAt, - THERNEXTAT: nextTherapySessionAt, - THERLASTAT: lastTherapySessionAt, - }, - }; - - return { crispSchema, mailchimpSchema }; -}; - -const serializeCourseData = (courseUser: CourseUserEntity) => { - const courseAcronymLowercase = getAcronym(courseUser.course.name).toLowerCase(); - const courseAcronymUppercase = getAcronym(courseUser.course.name); - - const data = { - course: courseUser.completed ? PROGRESS_STATUS.COMPLETED : PROGRESS_STATUS.STARTED, - sessions: courseUser.sessionUser - .map( - (sessionUser) => - `${getAcronym(sessionUser.session.name)}:${sessionUser.completed ? 'C' : 'S'}`, - ) - .join('; '), - }; - - const crispSchema = { - [`course_${courseAcronymLowercase}`]: data.course, - [`course_${courseAcronymLowercase}_sessions`]: data.sessions, - }; - - const mailchimpSchema = { - merge_fields: { - [`C_${courseAcronymUppercase}`]: data.course, - [`C_${courseAcronymUppercase}_S`]: data.sessions, - }, - } as ListMemberPartial; - - return { crispSchema, mailchimpSchema }; -}; diff --git a/src/webhooks/webhooks.module.ts b/src/webhooks/webhooks.module.ts index 2c46cd7a..f5973cb8 100644 --- a/src/webhooks/webhooks.module.ts +++ b/src/webhooks/webhooks.module.ts @@ -13,6 +13,7 @@ import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { EventLoggerService } from 'src/event-logger/event-logger.service'; import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { WebhooksController } from './webhooks.controller'; import { WebhooksService } from './webhooks.service'; @@ -34,6 +35,7 @@ import { WebhooksService } from './webhooks.service'; WebhooksService, CoursePartnerService, PartnerService, + ServiceUserProfilesService, SlackMessageClient, EventLoggerService, ], diff --git a/src/webhooks/webhooks.service.spec.ts b/src/webhooks/webhooks.service.spec.ts index 16c50902..9541588e 100644 --- a/src/webhooks/webhooks.service.spec.ts +++ b/src/webhooks/webhooks.service.spec.ts @@ -15,8 +15,8 @@ import { UserEntity } from 'src/entities/user.entity'; import { EVENT_NAME } from 'src/event-logger/event-logger.interface'; import { EventLoggerService } from 'src/event-logger/event-logger.service'; import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { SIMPLYBOOK_ACTION_ENUM, STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; -import { createMailchimpCourseMergeField } from 'src/utils/serviceUserProfiles'; import StoryblokClient from 'storyblok-js-client'; import { mockCourse, @@ -71,7 +71,6 @@ jest.mock('src/api/simplybook/simplybook-api', () => { }; }); jest.mock('src/api/crisp/crisp-api'); -jest.mock('src/utils/serviceUserProfiles'); describe('WebhooksService', () => { let service: WebhooksService; @@ -103,6 +102,7 @@ describe('WebhooksService', () => { const mockedPartnerAdminRepository = createMock>( mockPartnerAdminRepositoryMethods, ); + const mockedServiceUserProfilesService = createMock(); beforeEach(async () => { jest.clearAllMocks(); @@ -145,6 +145,10 @@ describe('WebhooksService', () => { provide: getRepositoryToken(EventLogEntity), useValue: mockedEventLogRepository, }, + { + provide: ServiceUserProfilesService, + useValue: mockedServiceUserProfilesService, + }, { provide: CoursePartnerService, useValue: mockedCoursePartnerService, @@ -408,7 +412,9 @@ describe('WebhooksService', () => { }); expect(courseSaveRepoSpy).toHaveBeenCalledWith(mockCourse); - expect(createMailchimpCourseMergeField).toHaveBeenCalledWith(mockCourse.name); + expect(mockedServiceUserProfilesService.createMailchimpCourseMergeField).toHaveBeenCalledWith( + mockCourse.name, + ); courseFindOneRepoSpy.mockClear(); courseCreateRepoSpy.mockClear(); courseSaveRepoSpy.mockClear(); diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts index 62f7f925..34401428 100644 --- a/src/webhooks/webhooks.service.ts +++ b/src/webhooks/webhooks.service.ts @@ -9,20 +9,17 @@ import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { EventLoggerService } from 'src/event-logger/event-logger.service'; import { ZapierSimplybookBodyDto } from 'src/partner-access/dtos/zapier-body.dto'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { IUser } from 'src/user/user.interface'; import { serializeZapierSimplyBookDtoToTherapySessionEntity } from 'src/utils/serialize'; -import { - createMailchimpCourseMergeField, - updateServiceUserProfilesTherapy, -} from 'src/utils/serviceUserProfiles'; import { WebhookCreateEventLogDto } from 'src/webhooks/dto/webhook-create-event-log.dto'; import StoryblokClient from 'storyblok-js-client'; import { ILike, MoreThan, Repository } from 'typeorm'; import { CoursePartnerService } from '../course-partner/course-partner.service'; import { + isProduction, SIMPLYBOOK_ACTION_ENUM, STORYBLOK_STORY_STATUS_ENUM, - isProduction, storyblokToken, } from '../utils/constants'; import { StoryDto } from './dto/story.dto'; @@ -41,6 +38,7 @@ export class WebhooksService { @InjectRepository(TherapySessionEntity) private therapySessionRepository: Repository, private eventLoggerService: EventLoggerService, + private serviceUserProfilesService: ServiceUserProfilesService, private slackMessageClient: SlackMessageClient, ) {} @@ -127,7 +125,7 @@ export class WebhooksService { }, }); - updateServiceUserProfilesTherapy(partnerAccesses, user.email); + this.serviceUserProfilesService.updateServiceUserProfilesTherapy(partnerAccesses, user.email); this.logger.log( `Update therapy session webhook function COMPLETED for ${action} - ${user.email} - ${booking_code} - userId ${user_id}`, @@ -253,7 +251,10 @@ export class WebhooksService { therapySession: true, }, }); - updateServiceUserProfilesTherapy(updatedPartnerAccesses, user.email); + this.serviceUserProfilesService.updateServiceUserProfilesTherapy( + updatedPartnerAccesses, + user.email, + ); return therapySession; } catch (err) { const error = `newPartnerAccessTherapy - error saving new therapy session and partner access - email ${user.email} userId ${user.id} - ${err}`; @@ -304,7 +305,7 @@ export class WebhooksService { course.slug = story.full_slug; } else { course = this.courseRepository.create(storyData); - createMailchimpCourseMergeField(courseName); + this.serviceUserProfilesService.createMailchimpCourseMergeField(courseName); } course.name = courseName; diff --git a/yarn.lock b/yarn.lock index daf960c8..833eb3ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,6 +22,18 @@ rxjs "7.8.1" source-map "0.7.4" +"@angular-devkit/core@17.3.8": + version "17.3.8" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-17.3.8.tgz#8679cacf84cf79764f027811020e235ab32016d2" + integrity sha512-Q8q0voCGudbdCgJ7lXdnyaxKHbNQBARH68zPQV72WT8NWy+Gw/tys870i6L58NWbBaCJEUcIj/kb6KoakSRu+Q== + dependencies: + ajv "8.12.0" + ajv-formats "2.1.1" + jsonc-parser "3.2.1" + picomatch "4.0.1" + rxjs "7.8.1" + source-map "0.7.4" + "@angular-devkit/schematics-cli@17.1.2": version "17.1.2" resolved "https://registry.yarnpkg.com/@angular-devkit/schematics-cli/-/schematics-cli-17.1.2.tgz#7a77e8294071e5ba569e2ffb567b3301d1db3f07" @@ -45,6 +57,17 @@ ora "5.4.1" rxjs "7.8.1" +"@angular-devkit/schematics@17.3.8": + version "17.3.8" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-17.3.8.tgz#f853eb21682aadfb6667e090b5b509fc95ce8442" + integrity sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg== + dependencies: + "@angular-devkit/core" "17.3.8" + jsonc-parser "3.2.1" + magic-string "0.30.8" + ora "5.4.1" + rxjs "7.8.1" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.2": version "7.24.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" @@ -385,11 +408,16 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.3.0", "@eslint/js@^9.0.0": +"@eslint/js@9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.3.0.tgz#2e8f65c9c55227abc4845b1513c69c32c679d8fe" integrity sha512-niBqk8iwv96+yuTwjM6bWg8ovzAPF9qkICsGtcoa5/dmqcEMfdwNAX7+/OHcJHc7wj7XqPxH98oAHytFYlw6Sw== +"@eslint/js@^9.8.0": + version "9.8.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.8.0.tgz#ae9bc14bb839713c5056f5018bcefa955556d3a4" + integrity sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA== + "@fastify/busboy@^2.0.0", "@fastify/busboy@^2.1.0": version "2.1.1" resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" @@ -1214,14 +1242,14 @@ webpack "5.90.1" webpack-node-externals "3.0.0" -"@nestjs/common@^10.3.6": - version "10.3.8" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.8.tgz#2dada4dc8b53aa1630d00bdea57db4453f066c4b" - integrity sha512-P+vPEIvqx2e+fonsYVlFXKvoChyJ8Tq+lfpqdVFqblovHbFr3kZ/nYX0cPs+XuW6bnRT8tz0SSR9XBGU43kJhw== +"@nestjs/common@^10.3.10": + version "10.3.10" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.10.tgz#d8825d55a50a04e33080c9188e6a5b03235d19f2" + integrity sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg== dependencies: uid "2.0.2" iterare "1.2.1" - tslib "2.6.2" + tslib "2.6.3" "@nestjs/config@^3.2.2": version "3.2.2" @@ -1261,15 +1289,15 @@ multer "1.4.4-lts.1" tslib "2.6.2" -"@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.1.1": - version "10.1.1" - resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.1.1.tgz#a67fb178a7ad6025ccc3314910b077ac454fcdf3" - integrity sha512-o4lfCnEeIkfJhGBbLZxTuVWcGuqDCFwg5OrvpgRUBM7vI/vONvKKiB5riVNpO+JqXoH0I42NNeDb0m4V5RREig== +"@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.1.3": + version "10.1.3" + resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.1.3.tgz#8bd80ab9fab6a02586524bd2c545b0ea787cf62c" + integrity sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg== dependencies: - "@angular-devkit/core" "17.1.2" - "@angular-devkit/schematics" "17.1.2" + "@angular-devkit/core" "17.3.8" + "@angular-devkit/schematics" "17.3.8" comment-json "4.2.3" - jsonc-parser "3.2.1" + jsonc-parser "3.3.1" pluralize "8.0.0" "@nestjs/swagger@^7.4.0": @@ -1812,62 +1840,62 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz#f5f5da52db674b1f2cdb9d5f3644e5b2ec750465" - integrity sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A== +"@typescript-eslint/eslint-plugin@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz#c8ed1af1ad2928ede5cdd207f7e3090499e1f77b" + integrity sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "7.16.1" - "@typescript-eslint/type-utils" "7.16.1" - "@typescript-eslint/utils" "7.16.1" - "@typescript-eslint/visitor-keys" "7.16.1" + "@typescript-eslint/scope-manager" "7.17.0" + "@typescript-eslint/type-utils" "7.17.0" + "@typescript-eslint/utils" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" graphemer "^1.4.0" ignore "^5.3.1" natural-compare "^1.4.0" ts-api-utils "^1.3.0" -"@typescript-eslint/parser@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.16.1.tgz#84c581cf86c8b2becd48d33ddc41a6303d57b274" - integrity sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA== +"@typescript-eslint/parser@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.17.0.tgz#be8e32c159190cd40a305a2121220eadea5a88e7" + integrity sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A== dependencies: - "@typescript-eslint/scope-manager" "7.16.1" - "@typescript-eslint/types" "7.16.1" - "@typescript-eslint/typescript-estree" "7.16.1" - "@typescript-eslint/visitor-keys" "7.16.1" + "@typescript-eslint/scope-manager" "7.17.0" + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/typescript-estree" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.16.1.tgz#2b43041caabf8ddd74512b8b550b9fc53ca3afa1" - integrity sha512-nYpyv6ALte18gbMz323RM+vpFpTjfNdyakbf3nsLvF43uF9KeNC289SUEW3QLZ1xPtyINJ1dIsZOuWuSRIWygw== +"@typescript-eslint/scope-manager@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz#e072d0f914662a7bfd6c058165e3c2b35ea26b9d" + integrity sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA== dependencies: - "@typescript-eslint/types" "7.16.1" - "@typescript-eslint/visitor-keys" "7.16.1" + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" -"@typescript-eslint/type-utils@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.16.1.tgz#4d7ae4f3d9e3c8cbdabae91609b1a431de6aa6ca" - integrity sha512-rbu/H2MWXN4SkjIIyWcmYBjlp55VT+1G3duFOIukTNFxr9PI35pLc2ydwAfejCEitCv4uztA07q0QWanOHC7dA== +"@typescript-eslint/type-utils@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz#c5da78feb134c9c9978cbe89e2b1a589ed22091a" + integrity sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA== dependencies: - "@typescript-eslint/typescript-estree" "7.16.1" - "@typescript-eslint/utils" "7.16.1" + "@typescript-eslint/typescript-estree" "7.17.0" + "@typescript-eslint/utils" "7.17.0" debug "^4.3.4" ts-api-utils "^1.3.0" -"@typescript-eslint/types@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.16.1.tgz#bbab066276d18e398bc64067b23f1ce84dfc6d8c" - integrity sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ== +"@typescript-eslint/types@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.17.0.tgz#7ce8185bdf06bc3494e73d143dbf3293111b9cff" + integrity sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A== -"@typescript-eslint/typescript-estree@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.1.tgz#9b145ba4fd1dde1986697e1ce57dc501a1736dd3" - integrity sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ== +"@typescript-eslint/typescript-estree@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz#dcab3fea4c07482329dd6107d3c6480e228e4130" + integrity sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw== dependencies: - "@typescript-eslint/types" "7.16.1" - "@typescript-eslint/visitor-keys" "7.16.1" + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -1875,22 +1903,22 @@ semver "^7.6.0" ts-api-utils "^1.3.0" -"@typescript-eslint/utils@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.16.1.tgz#df42dc8ca5a4603016fd102db0346cdab415cdb7" - integrity sha512-WrFM8nzCowV0he0RlkotGDujx78xudsxnGMBHI88l5J8wEhED6yBwaSLP99ygfrzAjsQvcYQ94quDwI0d7E1fA== +"@typescript-eslint/utils@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.17.0.tgz#815cd85b9001845d41b699b0ce4f92d6dfb84902" + integrity sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw== dependencies: "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "7.16.1" - "@typescript-eslint/types" "7.16.1" - "@typescript-eslint/typescript-estree" "7.16.1" + "@typescript-eslint/scope-manager" "7.17.0" + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/typescript-estree" "7.17.0" -"@typescript-eslint/visitor-keys@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.1.tgz#4287bcf44c34df811ff3bb4d269be6cfc7d8c74b" - integrity sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg== +"@typescript-eslint/visitor-keys@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz#680465c734be30969e564b4647f38d6cdf49bfb0" + integrity sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A== dependencies: - "@typescript-eslint/types" "7.16.1" + "@typescript-eslint/types" "7.17.0" eslint-visitor-keys "^3.4.3" "@tyriar/fibonacci-heap@^2.0.7": @@ -3460,9 +3488,9 @@ fast-safe-stringify@2.1.1, fast-safe-stringify@^2.1.1: integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== fast-xml-parser@^4.3.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz#341cc98de71e9ba9e651a67f41f1752d1441a501" - integrity sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg== + version "4.4.1" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" + integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== dependencies: strnum "^1.0.5" @@ -4898,6 +4926,11 @@ jsonc-parser@3.2.1: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.1.tgz#031904571ccf929d7670ee8c547545081cb37f1a" integrity sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA== +jsonc-parser@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" @@ -5180,6 +5213,13 @@ magic-string@0.30.5: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" +magic-string@0.30.8: + version "0.30.8" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.8.tgz#14e8624246d2bedba70d5462aa99ac9681844613" + integrity sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.15" + make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -5888,6 +5928,11 @@ picomatch@3.0.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-3.0.1.tgz#817033161def55ec9638567a2f3bbc876b3e7516" integrity sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag== +picomatch@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.1.tgz#68c26c8837399e5819edce48590412ea07f17a07" + integrity sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -5956,10 +6001,10 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^3.2.5: - version "3.2.5" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" - integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== +prettier@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== pretty-bytes@^5.6.0: version "5.6.0" @@ -7102,14 +7147,14 @@ typeorm@^0.3.20: uuid "^9.0.0" yargs "^17.6.2" -typescript-eslint@^7.16.1: - version "7.16.1" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-7.16.1.tgz#4855e11985b3dbd13a94b4e7e6523b2ec5d1c759" - integrity sha512-889oE5qELj65q/tGeOSvlreNKhimitFwZqQ0o7PcWC7/lgRkAMknznsCsV8J8mZGTP/Z+cIbX8accf2DE33hrA== +typescript-eslint@^7.17.0: + version "7.17.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-7.17.0.tgz#cc5eddafd38b3c1fe8a52826469d5c78700b7aa7" + integrity sha512-spQxsQvPguduCUfyUvLItvKqK3l8KJ/kqs5Pb/URtzQ5AC53Z6us32St37rpmlt2uESG23lOFpV4UErrmy4dZQ== dependencies: - "@typescript-eslint/eslint-plugin" "7.16.1" - "@typescript-eslint/parser" "7.16.1" - "@typescript-eslint/utils" "7.16.1" + "@typescript-eslint/eslint-plugin" "7.17.0" + "@typescript-eslint/parser" "7.17.0" + "@typescript-eslint/utils" "7.17.0" typescript@5.3.3: version "5.3.3"