From 60ed32072bf05659750f08ff4595883bfe17f0be Mon Sep 17 00:00:00 2001 From: Denise Buder Date: Fri, 19 Jan 2024 14:54:13 +0100 Subject: [PATCH 1/3] add contact list brevo api functionality to target groups --- demo/api/schema.gql | 43 +++---- .../dto/brevo-contact-attributes.ts | 4 + .../brevo-api/brevo-api-contact.service.ts | 59 +++++++++- .../brevo-api/dto/brevo-api-contact-list.ts | 28 +++++ .../src/brevo-contact/brevo-contact.module.ts | 13 +- .../brevo-contact/brevo-contact.resolver.ts | 41 +++++-- .../brevo-contact/brevo-contacts.service.ts | 32 ++++- .../brevo-contact/dto/brevo-contacts.args.ts | 38 ++++-- .../dto/subscribe-input.factory.ts | 19 ++- packages/api/src/brevo-module.ts | 8 +- .../entity/target-group-entity.factory.ts | 2 +- .../src/target-group/target-group.module.ts | 5 +- .../src/target-group/target-group.resolver.ts | 73 ++++++++++-- .../src/target-group/target-groups.service.ts | 111 +++++++++++++++++- packages/api/src/types.ts | 2 +- 15 files changed, 409 insertions(+), 69 deletions(-) create mode 100644 packages/api/src/brevo-api/dto/brevo-api-contact-list.ts diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 250ef450..8ad9f94a 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -67,6 +67,8 @@ type DamFileLicense { enum LicenseType { ROYALTY_FREE RIGHTS_MANAGED + SUBSCRIPTION + MICRO } """ @@ -264,8 +266,6 @@ type DamFile { license: DamFileLicense createdAt: DateTime! updatedAt: DateTime! - importSourceId: String - importSourceType: String fileUrl: String! duplicates: [DamFile!]! damPath: String! @@ -358,6 +358,24 @@ type PaginatedBrevoContacts { totalCount: Int! } +type TargetGroup implements DocumentInterface { + id: ID! + updatedAt: DateTime! + createdAt: DateTime! + title: String! + isMainList: Boolean! + brevoId: Int! + totalSubscribers: Int! + totalContactsBlocked: Int! + scope: EmailCampaignContentScope! + filters: BrevoContactFilterAttributes +} + +type PaginatedTargetGroups { + nodes: [TargetGroup!]! + totalCount: Int! +} + type EmailCampaign implements DocumentInterface { id: ID! updatedAt: DateTime! @@ -385,24 +403,6 @@ type PaginatedEmailCampaigns { totalCount: Int! } -type TargetGroup implements DocumentInterface { - id: ID! - updatedAt: DateTime! - createdAt: DateTime! - title: String! - isMainList: Boolean! - brevoId: Int! - totalSubscribers: Int! - totalContactsBlocked: Int! - scope: EmailCampaignContentScope! - filters: BrevoContactFilterAttributes -} - -type PaginatedTargetGroups { - nodes: [TargetGroup!]! - totalCount: Int! -} - input PageTreeNodeScopeInput { domain: String! language: String! @@ -455,7 +455,7 @@ type Query { products(offset: Int! = 0, limit: Int! = 25, search: String, filter: ProductFilter, sort: [ProductSort!]): PaginatedProducts! mainMenu(scope: PageTreeNodeScopeInput!): [PageTreeNode!]! brevoContact(id: Int!): BrevoContact! - brevoContacts(offset: Int! = 0, limit: Int! = 25, email: String): PaginatedBrevoContacts! + brevoContacts(targetGroupId: ID, email: String, scope: EmailCampaignContentScopeInput!, offset: Int! = 0, limit: Int! = 25): PaginatedBrevoContacts! emailCampaign(id: ID!): EmailCampaign! emailCampaigns(scope: EmailCampaignContentScopeInput!, search: String, filter: EmailCampaignFilter, sort: [EmailCampaignSort!], offset: Int! = 0, limit: Int! = 25): PaginatedEmailCampaigns! targetGroup(id: ID!): TargetGroup! @@ -796,6 +796,7 @@ enum SubscribeResponse { input SubscribeInput { email: String! redirectionUrl: String! + scope: EmailCampaignContentScopeInput! attributes: BrevoContactAttributesInput! } diff --git a/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts b/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts index 2ee3c9a5..6eb00d1c 100644 --- a/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts +++ b/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts @@ -28,6 +28,10 @@ export class BrevoContactAttributes { @ObjectType() @InputType("BrevoContactFilterAttributesInput") export class BrevoContactFilterAttributes { + // index signature to match Record in BrevoContactFilterAttributesInterface + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: Array | undefined; + @Field(() => [BrevoContactSalutation], { nullable: true }) @IsEnum(BrevoContactSalutation, { each: true }) @Enum({ items: () => BrevoContactSalutation, array: true }) diff --git a/packages/api/src/brevo-api/brevo-api-contact.service.ts b/packages/api/src/brevo-api/brevo-api-contact.service.ts index 26cc0814..50a9859c 100644 --- a/packages/api/src/brevo-api/brevo-api-contact.service.ts +++ b/packages/api/src/brevo-api/brevo-api-contact.service.ts @@ -1,12 +1,14 @@ import { Inject, Injectable } from "@nestjs/common"; import * as SibApiV3Sdk from "@sendinblue/client"; -import { BrevoContactInterface } from "src/brevo-contact/dto/brevo-contact.factory"; import { BrevoContactAttributesInterface } from "src/types"; +import { BrevoContactInterface } from "../brevo-contact/dto/brevo-contact.factory"; import { BrevoContactUpdateInput } from "../brevo-contact/dto/brevo-contact.input"; import { BrevoModuleConfig } from "../config/brevo-module.config"; import { BREVO_MODULE_CONFIG } from "../config/brevo-module.constants"; +import { TargetGroupInputInterface } from "../target-group/dto/target-group-input.factory"; import { isErrorFromBrevo } from "./brevo-api.utils"; +import { BrevoApiContactList } from "./dto/brevo-api-contact-list"; export interface CreateDoubleOptInContactData { email: string; @@ -23,7 +25,7 @@ export class BrevoApiContactsService { this.contactsApi.setApiKey(SibApiV3Sdk.ContactsApiApiKeys.apiKey, config.brevo.apiKey); } - public async createDoubleOptInContact( + public async createDoubleOptInBrevoContact( { email, redirectionUrl, attributes }: CreateDoubleOptInContactData, brevoIds: number[], templateId: number, @@ -92,4 +94,57 @@ export class BrevoApiContactsService { await this.contactsApi.updateBatchContacts({ contacts: blacklistedContacts }); } + + public async createBrevoContactList(input: TargetGroupInputInterface): Promise { + const contactList = { + name: input.title, + folderId: 1, // folderId is required, folder #1 is created by default + }; + + const data = await this.contactsApi.createList(contactList); + return data.body.id; + } + + public async updateBrevoContactList(id: number, input: TargetGroupInputInterface): Promise { + const data = await this.contactsApi.updateList(id, { name: input.title }); + return data.response.statusCode === 204; + } + + public async deleteBrevoContactList(id: number): Promise { + const data = await this.contactsApi.deleteList(id); + return data.response.statusCode === 204; + } + + public async findBrevoContactListById(id: number): Promise { + const data = await this.contactsApi.getList(id); + return data.body; + } + + public async findBrevoContactListsByIds(ids: number[]): Promise { + const lists: BrevoApiContactList[] = []; + for await (const list of await this.getBrevoContactListResponses()) { + if (ids.includes(list.id)) { + lists.push(list); + } + } + + return lists; + } + + async *getBrevoContactListResponses(): AsyncGenerator { + const limit = 50; + let offset = 0; + + while (true) { + const listsResponse = await this.contactsApi.getLists(limit, offset); + const lists = listsResponse.body.lists ?? []; + + if (lists.length === 0) { + break; + } + yield* lists; + + offset += limit; + } + } } diff --git a/packages/api/src/brevo-api/dto/brevo-api-contact-list.ts b/packages/api/src/brevo-api/dto/brevo-api-contact-list.ts new file mode 100644 index 00000000..b5216a1d --- /dev/null +++ b/packages/api/src/brevo-api/dto/brevo-api-contact-list.ts @@ -0,0 +1,28 @@ +import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class BrevoApiContactList { + @Field(() => ID) + id: number; + + @Field(() => String) + createdAt: string; + + @Field(() => String) + name: string; + + @Field(() => Int) + totalBlacklisted: number; + + @Field(() => Int) + totalSubscribers: number; + + @Field(() => Int) + uniqueSubscribers: number; + + @Field(() => Int) + folderId: number; + + @Field(() => Boolean, { nullable: true }) + dynamicList?: boolean; +} diff --git a/packages/api/src/brevo-contact/brevo-contact.module.ts b/packages/api/src/brevo-contact/brevo-contact.module.ts index bfe96e6c..cb472a08 100644 --- a/packages/api/src/brevo-contact/brevo-contact.module.ts +++ b/packages/api/src/brevo-contact/brevo-contact.module.ts @@ -2,7 +2,8 @@ import { DynamicModule, Module, Type } from "@nestjs/common"; import { BrevoApiModule } from "../brevo-api/brevo-api.module"; import { ConfigModule } from "../config/config.module"; -import { BrevoContactAttributesInterface } from "../types"; +import { TargetGroupModule } from "../target-group/target-group.module"; +import { BrevoContactAttributesInterface, BrevoContactFilterAttributesInterface, EmailCampaignScopeInterface } from "../types"; import { createBrevoContactResolver } from "./brevo-contact.resolver"; import { BrevoContactsService } from "./brevo-contacts.service"; import { BrevoContactFactory } from "./dto/brevo-contact.factory"; @@ -12,18 +13,20 @@ import { IsValidRedirectURLConstraint } from "./validator/redirect-url.validator interface BrevoContactModuleConfig { BrevoContactAttributes?: Type; + Scope: Type; + BrevoFilterAttributes?: Type; } @Module({}) export class BrevoContactModule { - static register({ BrevoContactAttributes }: BrevoContactModuleConfig): DynamicModule { + static register({ BrevoContactAttributes, Scope, BrevoFilterAttributes }: BrevoContactModuleConfig): DynamicModule { const BrevoContact = BrevoContactFactory.create({ BrevoContactAttributes }); - const BrevoContactSubscribeInput = SubscribeInputFactory.create({ BrevoContactAttributes }); - const BrevoContactResolver = createBrevoContactResolver({ BrevoContact, BrevoContactSubscribeInput }); + const BrevoContactSubscribeInput = SubscribeInputFactory.create({ BrevoContactAttributes, Scope }); + const BrevoContactResolver = createBrevoContactResolver({ BrevoContact, BrevoContactSubscribeInput, Scope }); return { module: BrevoContactModule, - imports: [BrevoApiModule, ConfigModule], + imports: [BrevoApiModule, ConfigModule, TargetGroupModule.register({ Scope, BrevoFilterAttributes })], providers: [BrevoContactsService, BrevoContactResolver, EcgRtrListService, IsValidRedirectURLConstraint], }; } diff --git a/packages/api/src/brevo-contact/brevo-contact.resolver.ts b/packages/api/src/brevo-contact/brevo-contact.resolver.ts index 5f614cc1..e99a900b 100644 --- a/packages/api/src/brevo-contact/brevo-contact.resolver.ts +++ b/packages/api/src/brevo-contact/brevo-contact.resolver.ts @@ -1,14 +1,18 @@ import { PaginatedResponseFactory } from "@comet/cms-api"; +import { FilterQuery } from "@mikro-orm/core"; import { Inject, Type } from "@nestjs/common"; -import { Args, Int, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql"; +import { Args, ArgsType, Int, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql"; import { BrevoApiContactsService } from "../brevo-api/brevo-api-contact.service"; import { BrevoModuleConfig } from "../config/brevo-module.config"; import { BREVO_MODULE_CONFIG } from "../config/brevo-module.constants"; +import { TargetGroupInterface } from "../target-group/entity/target-group-entity.factory"; +import { TargetGroupsService } from "../target-group/target-groups.service"; +import { EmailCampaignScopeInterface } from "../types"; import { BrevoContactsService } from "./brevo-contacts.service"; import { BrevoContactInterface } from "./dto/brevo-contact.factory"; import { BrevoContactUpdateInput } from "./dto/brevo-contact.input"; -import { BrevoContactsArgs } from "./dto/brevo-contacts.args"; +import { BrevoContactsArgsFactory } from "./dto/brevo-contacts.args"; import { SubscribeInputInterface } from "./dto/subscribe-input.factory"; import { SubscribeResponse } from "./dto/subscribe-response.enum"; import { EcgRtrListService } from "./ecg-rtr-list/ecg-rtr-list.service"; @@ -16,13 +20,18 @@ import { EcgRtrListService } from "./ecg-rtr-list/ecg-rtr-list.service"; export function createBrevoContactResolver({ BrevoContact, BrevoContactSubscribeInput, + Scope, }: { BrevoContact: Type; BrevoContactSubscribeInput: Type; + Scope: Type; }): Type { @ObjectType() class PaginatedBrevoContacts extends PaginatedResponseFactory.create(BrevoContact) {} + @ArgsType() + class BrevoContactsArgs extends BrevoContactsArgsFactory.create({ Scope }) {} + @Resolver(() => BrevoContact) class BrevoContactResolver { constructor( @@ -30,6 +39,7 @@ export function createBrevoContactResolver({ private readonly brevoContactsApiService: BrevoApiContactsService, private readonly brevoContactsService: BrevoContactsService, private readonly ecgRtrListService: EcgRtrListService, + private readonly targetGroupService: TargetGroupsService, ) {} @Query(() => BrevoContact) @@ -38,10 +48,26 @@ export function createBrevoContactResolver({ } @Query(() => PaginatedBrevoContacts) - async brevoContacts(@Args() { offset, limit, email }: BrevoContactsArgs): Promise { - // TODO: add correct lists when brevo contact list is implemented - // 2 is the id of the first list in brevo that is created by default - const contactListId = 2; + async brevoContacts(@Args() { offset, limit, email, scope, targetGroupId }: BrevoContactsArgs): Promise { + const where: FilterQuery = { scope, isMainList: true }; + + if (targetGroupId) { + where.id = targetGroupId; + where.isMainList = false; + } + + let targetGroup = await this.targetGroupService.findOneTargetGroup(where); + + if (!targetGroup) { + // filtering for a specific target group, but it does not exist + if (targetGroupId) { + return new PaginatedBrevoContacts([], 0, { offset, limit }); + } + + // when there is no main target group for the scope, create one + targetGroup = await this.targetGroupService.createIfNotExistMainTargetGroupForScope(scope); + } + if (email) { const contact = await this.brevoContactsApiService.getContactInfoByEmail(email); if (contact) { @@ -50,7 +76,8 @@ export function createBrevoContactResolver({ return new PaginatedBrevoContacts([], 0, { offset, limit }); } - const [contacts, count] = await this.brevoContactsApiService.findContactsByListId(contactListId, limit, offset); + const [contacts, count] = await this.brevoContactsApiService.findContactsByListId(targetGroup?.brevoId, limit, offset); + return new PaginatedBrevoContacts(contacts, count, { offset, limit }); } diff --git a/packages/api/src/brevo-contact/brevo-contacts.service.ts b/packages/api/src/brevo-contact/brevo-contacts.service.ts index 91a00971..304ae522 100644 --- a/packages/api/src/brevo-contact/brevo-contacts.service.ts +++ b/packages/api/src/brevo-contact/brevo-contacts.service.ts @@ -1,17 +1,37 @@ import { Injectable } from "@nestjs/common"; -import { BrevoApiContactsService, CreateDoubleOptInContactData } from "../brevo-api/brevo-api-contact.service"; +import { BrevoApiContactsService } from "../brevo-api/brevo-api-contact.service"; +import { TargetGroupsService } from "../target-group/target-groups.service"; +import { SubscribeInputInterface } from "./dto/subscribe-input.factory"; import { SubscribeResponse } from "./dto/subscribe-response.enum"; @Injectable() export class BrevoContactsService { - constructor(private readonly brevoContactsApiService: BrevoApiContactsService) {} + constructor(private readonly brevoContactsApiService: BrevoApiContactsService, private readonly targetGroupService: TargetGroupsService) {} - public async createDoubleOptInContact(data: CreateDoubleOptInContactData, templateId: number): Promise { - // TODO: add correct lists when brevo contact list is implemented - const contactListId = 2; + public async createDoubleOptInContact(data: SubscribeInputInterface, templateId: number): Promise { + const mainTargetGroupForScope = await this.targetGroupService.createIfNotExistMainTargetGroupForScope(data.scope); - const created = await this.brevoContactsApiService.createDoubleOptInContact(data, [contactListId], templateId); + let offset = 0; + let totalCount = 0; + const targetGroupIds: number[] = []; + const limit = 50; + + do { + const [targetGroups, totalContactLists] = await this.targetGroupService.findNonMainTargetGroups(data, offset, limit); + totalCount = totalContactLists; + offset += targetGroups.length; + + for (const targetGroup of targetGroups) { + const contactIsInTargetGroup = this.targetGroupService.checkIfContactIsInTargetGroup(data, targetGroup.filters); + + if (contactIsInTargetGroup) { + targetGroupIds.push(targetGroup.brevoId); + } + } + } while (offset < totalCount); + + const created = await this.brevoContactsApiService.createDoubleOptInBrevoContact(data, [mainTargetGroupForScope.brevoId], templateId); if (created) { return SubscribeResponse.SUCCESSFUL; } diff --git a/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts b/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts index 8035c417..a76d10b5 100644 --- a/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts +++ b/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts @@ -1,12 +1,36 @@ import { OffsetBasedPaginationArgs } from "@comet/cms-api"; -import { ArgsType, Field } from "@nestjs/graphql"; -import { IsOptional } from "class-validator"; +import { Type } from "@nestjs/common"; +import { ArgsType, Field, ID } from "@nestjs/graphql"; +import { Type as TransformerType } from "class-transformer"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; -@ArgsType() -export class BrevoContactsArgs extends OffsetBasedPaginationArgs { - // TODO: add scope +import { EmailCampaignScopeInterface } from "../../types"; - @Field(() => String, { nullable: true }) - @IsOptional() +export interface BrevoContactsArgsInterface extends OffsetBasedPaginationArgs { + scope: EmailCampaignScopeInterface; + targetGroupId?: string; email?: string; } + +export class BrevoContactsArgsFactory { + static create({ Scope }: { Scope: Type }) { + @ArgsType() + class BrevoContactsArgs extends OffsetBasedPaginationArgs { + @Field(() => ID, { nullable: true }) + @IsString() + @IsOptional() + targetGroupId?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + email?: string; + + @Field(() => Scope) + @TransformerType(() => Scope) + @ValidateNested() + scope: EmailCampaignScopeInterface; + } + + return BrevoContactsArgs; + } +} diff --git a/packages/api/src/brevo-contact/dto/subscribe-input.factory.ts b/packages/api/src/brevo-contact/dto/subscribe-input.factory.ts index 19ea6cc5..d79a602c 100644 --- a/packages/api/src/brevo-contact/dto/subscribe-input.factory.ts +++ b/packages/api/src/brevo-contact/dto/subscribe-input.factory.ts @@ -1,18 +1,26 @@ import { Type } from "@nestjs/common"; import { Field, InputType } from "@nestjs/graphql"; -import { IsEmail, IsUrl, Validate } from "class-validator"; +import { Type as TransformerType } from "class-transformer"; +import { IsEmail, IsUrl, Validate, ValidateNested } from "class-validator"; -import { BrevoContactAttributesInterface } from "../../types"; +import { BrevoContactAttributesInterface, EmailCampaignScopeInterface } from "../../types"; import { IsValidRedirectURLConstraint } from "../validator/redirect-url.validator"; export interface SubscribeInputInterface { email: string; redirectionUrl: string; attributes?: BrevoContactAttributesInterface; + scope: EmailCampaignScopeInterface; } export class SubscribeInputFactory { - static create({ BrevoContactAttributes }: { BrevoContactAttributes?: Type }): Type { + static create({ + BrevoContactAttributes, + Scope, + }: { + BrevoContactAttributes?: Type; + Scope: Type; + }): Type { @InputType({ isAbstract: true }) class SubscribeInputBase implements SubscribeInputInterface { @Field() @@ -23,6 +31,11 @@ export class SubscribeInputFactory { @IsUrl({ require_tld: process.env.NODE_ENV === "production" }) @Validate(IsValidRedirectURLConstraint) redirectionUrl: string; + + @Field(() => Scope) + @TransformerType(() => Scope) + @ValidateNested() + scope: EmailCampaignScopeInterface; } if (BrevoContactAttributes) { diff --git a/packages/api/src/brevo-module.ts b/packages/api/src/brevo-module.ts index 306f3e3a..e1d8150a 100644 --- a/packages/api/src/brevo-module.ts +++ b/packages/api/src/brevo-module.ts @@ -15,9 +15,13 @@ export class BrevoModule { module: BrevoModule, imports: [ BrevoApiModule, - BrevoContactModule.register({ BrevoContactAttributes: config.brevo.BrevoContactAttributes }), + BrevoContactModule.register({ + BrevoContactAttributes: config.brevo.BrevoContactAttributes, + BrevoFilterAttributes: config.brevo.BrevoContactFilterAttributes, + Scope: config.Scope, + }), EmailCampaignModule.register(config), - TargetGroupModule.register({ Scope: config.Scope, BrevoFilterAttributes: config.brevo.BrevoContactFilterAttributes }), + TargetGroupModule, ConfigModule.forRoot(config), ], exports: [], diff --git a/packages/api/src/target-group/entity/target-group-entity.factory.ts b/packages/api/src/target-group/entity/target-group-entity.factory.ts index 8dc1d4ab..ce783044 100644 --- a/packages/api/src/target-group/entity/target-group-entity.factory.ts +++ b/packages/api/src/target-group/entity/target-group-entity.factory.ts @@ -17,7 +17,7 @@ export interface TargetGroupInterface { totalSubscribers: number; totalContactsBlocked: number; scope: EmailCampaignScopeInterface; - filters?: BrevoContactFilterAttributesInterface; + filters?: Type; } export class TargetGroupEntityFactory { diff --git a/packages/api/src/target-group/target-group.module.ts b/packages/api/src/target-group/target-group.module.ts index 14875f9b..21f26b14 100644 --- a/packages/api/src/target-group/target-group.module.ts +++ b/packages/api/src/target-group/target-group.module.ts @@ -1,7 +1,7 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { DynamicModule, Module, Type } from "@nestjs/common"; -import { BrevoModule } from "../brevo-module"; +import { BrevoApiModule } from "../brevo-api/brevo-api.module"; import { ConfigModule } from "../config/config.module"; import { BrevoContactFilterAttributesInterface, EmailCampaignScopeInterface } from "../types"; import { TargetGroupInputFactory } from "./dto/target-group-input.factory"; @@ -23,8 +23,9 @@ export class TargetGroupModule { return { module: TargetGroupModule, - imports: [ConfigModule, BrevoModule, MikroOrmModule.forFeature([TargetGroup])], + imports: [ConfigModule, BrevoApiModule, MikroOrmModule.forFeature([TargetGroup])], providers: [TargetGroupResolver, TargetGroupsService], + exports: [TargetGroupsService], }; } } diff --git a/packages/api/src/target-group/target-group.resolver.ts b/packages/api/src/target-group/target-group.resolver.ts index 23f2b5f1..a57e5d5f 100644 --- a/packages/api/src/target-group/target-group.resolver.ts +++ b/packages/api/src/target-group/target-group.resolver.ts @@ -2,9 +2,10 @@ import { PaginatedResponseFactory, SubjectEntity, validateNotModified } from "@c import { EntityManager, EntityRepository, FindOptions, wrap } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { Type } from "@nestjs/common"; -import { Args, ArgsType, ID, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql"; +import { Args, ArgsType, ID, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; import { EmailCampaignScopeInterface } from "src/types"; +import { BrevoApiContactsService } from "../brevo-api/brevo-api-contact.service"; import { DynamicDtoValidationPipe } from "../validation/dynamic-dto-validation.pipe"; import { TargetGroupArgsFactory } from "./dto/target-group-args.factory"; import { TargetGroupInputInterface } from "./dto/target-group-input.factory"; @@ -30,6 +31,7 @@ export function createTargetGroupsResolver({ class TargetGroupResolver { constructor( private readonly targetGroupsService: TargetGroupsService, + private readonly brevoApiContactsService: BrevoApiContactsService, private readonly entityManager: EntityManager, @InjectRepository("TargetGroup") private readonly repository: EntityRepository, ) {} @@ -57,6 +59,19 @@ export function createTargetGroupsResolver({ } const [entities, totalCount] = await this.repository.findAndCount(where, options); + + const brevoContactLists = await this.brevoApiContactsService.findBrevoContactListsByIds(entities.map((list) => list.brevoId)); + + for (const contactList of entities) { + const brevoContactList = brevoContactLists.find((item) => item.id === contactList.brevoId); + + if (brevoContactList) { + contactList.totalSubscribers = brevoContactList.uniqueSubscribers; + // TODO: brevo is returning a wrong value for totalBlacklisted + // contactList.totalContactsBlocked = brevoContactList.totalBlacklisted; + } + } + return new PaginatedTargetGroups(entities, totalCount); } @@ -66,18 +81,19 @@ export function createTargetGroupsResolver({ scope: typeof Scope, @Args("input", { type: () => TargetGroupInput }, new DynamicDtoValidationPipe(TargetGroupInput)) input: TargetGroupInputInterface, ): Promise { - const targetGroup = this.repository.create({ - ...input, - scope, - // TODO: add correct brevo id - brevoId: 1, - // TODO: add correct logic for main list - isMainList: false, - }); + const brevoId = await this.brevoApiContactsService.createBrevoContactList(input); - await this.entityManager.flush(); + if (brevoId) { + const targetGroup = this.repository.create({ ...input, brevoId, scope, isMainList: false }); - return targetGroup; + await this.entityManager.flush(); + + await this.targetGroupsService.assignContactsToContactList(input, targetGroup.brevoId, targetGroup.scope); + + return targetGroup; + } + + throw new Error("Brevo Error: Could not create target group in brevo"); } @Mutation(() => TargetGroup) @@ -93,6 +109,15 @@ export function createTargetGroupsResolver({ validateNotModified(targetGroup, lastUpdatedAt); } + await this.targetGroupsService.assignContactsToContactList(input, targetGroup.brevoId, targetGroup.scope); + + if (input.title !== targetGroup.title) { + const successfullyUpdatedContactList = await this.brevoApiContactsService.updateBrevoContactList(targetGroup.brevoId, input); + if (!successfullyUpdatedContactList) { + throw Error("Brevo Error: Could not update contact list"); + } + } + wrap(targetGroup).assign({ ...input, }); @@ -106,10 +131,36 @@ export function createTargetGroupsResolver({ @SubjectEntity(TargetGroup) async deleteTargetGroup(@Args("id", { type: () => ID }) id: string): Promise { const targetGroup = await this.repository.findOneOrFail(id); + + const isDeletedInBrevo = await this.brevoApiContactsService.deleteBrevoContactList(targetGroup.brevoId); + + if (!isDeletedInBrevo) { + return false; + } + await this.entityManager.remove(targetGroup); await this.entityManager.flush(); + return true; } + + @ResolveField() + async totalSubscribers(@Parent() targetGroup: TargetGroupInterface): Promise { + if (targetGroup.totalSubscribers !== undefined) return targetGroup.totalSubscribers; + + const { uniqueSubscribers } = await this.brevoApiContactsService.findBrevoContactListById(targetGroup.brevoId); + + return uniqueSubscribers; + } + + @ResolveField() + async totalContactsBlocked(@Parent() targetGroup: TargetGroupInterface): Promise { + if (targetGroup.totalContactsBlocked !== undefined) return targetGroup.totalContactsBlocked; + + const { totalBlacklisted } = await this.brevoApiContactsService.findBrevoContactListById(targetGroup.brevoId); + + return totalBlacklisted; + } } return TargetGroupResolver; diff --git a/packages/api/src/target-group/target-groups.service.ts b/packages/api/src/target-group/target-groups.service.ts index c1ffb907..b80d2d1b 100644 --- a/packages/api/src/target-group/target-groups.service.ts +++ b/packages/api/src/target-group/target-groups.service.ts @@ -1,12 +1,24 @@ import { filtersToMikroOrmQuery, searchToMikroOrmQuery } from "@comet/cms-api"; -import { ObjectQuery } from "@mikro-orm/core"; +import { EntityManager, EntityRepository, FilterQuery, ObjectQuery } from "@mikro-orm/core"; +import { InjectRepository } from "@mikro-orm/nestjs"; import { Injectable } from "@nestjs/common"; +import { BrevoApiContactsService } from "../brevo-api/brevo-api-contact.service"; +import { BrevoContactInterface } from "../brevo-contact/dto/brevo-contact.factory"; +import { SubscribeInputInterface } from "../brevo-contact/dto/subscribe-input.factory"; +import { BrevoContactAttributesInterface, BrevoContactFilterAttributesInterface, EmailCampaignScopeInterface } from "../types"; import { TargetGroupFilter } from "./dto/target-group.filter"; +import { TargetGroupInputInterface } from "./dto/target-group-input.factory"; import { TargetGroupInterface } from "./entity/target-group-entity.factory"; @Injectable() export class TargetGroupsService { + constructor( + @InjectRepository("TargetGroup") private readonly repository: EntityRepository, + private readonly brevoApiContactsService: BrevoApiContactsService, + private readonly entityManager: EntityManager, + ) {} + getFindCondition(options: { search?: string; filter?: TargetGroupFilter }): ObjectQuery { const andFilters = []; @@ -20,4 +32,101 @@ export class TargetGroupsService { return andFilters.length > 0 ? { $and: andFilters } : {}; } + + public checkIfContactIsInTargetGroup( + contactAttributes?: BrevoContactAttributesInterface, + filters?: BrevoContactFilterAttributesInterface, + ): boolean { + if (!contactAttributes) return false; + + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (!value) continue; + if (value.includes(contactAttributes[key])) { + continue; + } + + return false; + } + return true; + } + + return true; + } + + public async assignContactsToContactList(input: TargetGroupInputInterface, brevoId: number, scope: EmailCampaignScopeInterface): Promise { + const mainScopeTargetGroupList = await this.repository.findOneOrFail({ scope, isMainList: true }); + + let offset = 0; + let totalCount = 0; + do { + const [contacts, totalContacts] = await this.brevoApiContactsService.findContactsByListId(mainScopeTargetGroupList.brevoId, 50, offset); + totalCount = totalContacts; + offset += contacts.length; + + const contactsInContactList: BrevoContactInterface[] = []; + const contactsNotInContactList: BrevoContactInterface[] = []; + + for (const contact of contacts) { + const contactIsInTargetGroup = this.checkIfContactIsInTargetGroup(contact.attributes, input.filters); + + if (contactIsInTargetGroup) { + contactsInContactList.push(contact); + } else { + contactsNotInContactList.push(contact); + } + } + + if (contactsInContactList.length > 0) { + await this.brevoApiContactsService.updateMultipleContacts( + contactsInContactList.map((contact) => ({ id: contact.id, listIds: [brevoId] })), + ); + } + if (contactsNotInContactList.length > 0) { + await this.brevoApiContactsService.updateMultipleContacts( + contactsNotInContactList.map((contact) => ({ id: contact.id, unlinkListIds: [brevoId] })), + ); + } + } while (offset < totalCount); + + return true; + } + + async findNonMainTargetGroups(data: SubscribeInputInterface, offset: number, limit: number): Promise<[TargetGroupInterface[], number]> { + const [targetGroups, totalContactLists] = await this.repository.findAndCount( + { + scope: data.scope, + isMainList: false, + }, + { limit, offset }, + ); + + return [targetGroups, totalContactLists]; + } + + async findOneTargetGroup(where: FilterQuery): Promise { + const targetGroup = await this.repository.findOne(where); + return targetGroup; + } + + public async createIfNotExistMainTargetGroupForScope(scope: EmailCampaignScopeInterface): Promise { + const mainList = await this.repository.findOne({ scope, isMainList: true }); + + if (mainList) { + return mainList; + } + + const title = "Main list for current scope"; + const brevoId = await this.brevoApiContactsService.createBrevoContactList({ title }); + + if (brevoId) { + const mainTargetGroupForScope = this.repository.create({ title, brevoId, scope, isMainList: true }); + + await this.entityManager.flush(); + + return mainTargetGroupForScope; + } + + throw new Error("Brevo Error: Could not create contact list"); + } } diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index f6a34314..2b078912 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -2,7 +2,7 @@ export type BrevoContactAttributesInterface = Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type BrevoContactFilterAttributesInterface = Record; +export type BrevoContactFilterAttributesInterface = Record | undefined>; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type EmailCampaignScopeInterface = Record; From 92b02d58cba72ccf10e5b56b291f6c63f192f927 Mon Sep 17 00:00:00 2001 From: Denise Buder Date: Tue, 23 Jan 2024 08:32:09 +0100 Subject: [PATCH 2/3] fix wrong Record type in comment --- demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts b/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts index 6eb00d1c..db1be27d 100644 --- a/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts +++ b/demo/api/src/brevo-contact/dto/brevo-contact-attributes.ts @@ -28,7 +28,7 @@ export class BrevoContactAttributes { @ObjectType() @InputType("BrevoContactFilterAttributesInput") export class BrevoContactFilterAttributes { - // index signature to match Record in BrevoContactFilterAttributesInterface + // index signature to match Array | undefined in BrevoContactFilterAttributesInterface // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: Array | undefined; From aef2ba95f335c49529309dcd33be2aedd2bb0ef1 Mon Sep 17 00:00:00 2001 From: Denise Buder Date: Tue, 23 Jan 2024 08:33:52 +0100 Subject: [PATCH 3/3] add email validator to brevo contact args --- packages/api/src/brevo-contact/dto/brevo-contacts.args.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts b/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts index a76d10b5..991cf4c5 100644 --- a/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts +++ b/packages/api/src/brevo-contact/dto/brevo-contacts.args.ts @@ -2,7 +2,7 @@ import { OffsetBasedPaginationArgs } from "@comet/cms-api"; import { Type } from "@nestjs/common"; import { ArgsType, Field, ID } from "@nestjs/graphql"; import { Type as TransformerType } from "class-transformer"; -import { IsOptional, IsString, ValidateNested } from "class-validator"; +import { IsEmail, IsOptional, IsString, ValidateNested } from "class-validator"; import { EmailCampaignScopeInterface } from "../../types"; @@ -23,6 +23,7 @@ export class BrevoContactsArgsFactory { @Field(() => String, { nullable: true }) @IsOptional() + @IsEmail() email?: string; @Field(() => Scope)