From f409ab96e1247cf293cadd7ba9afadd0cfe2bc10 Mon Sep 17 00:00:00 2001 From: Denise Buder Date: Mon, 22 Jan 2024 10:19:55 +0100 Subject: [PATCH] add brevo api campaign functionality to existing email campaigns --- README.md | 3 + demo/api/schema.gql | 87 ++++++++++--- demo/api/src/app.module.ts | 13 +- demo/api/src/config/config.ts | 7 ++ demo/api/src/config/environment-variables.ts | 9 ++ packages/api/package.json | 5 +- .../brevo-api/brevo-api-campaigns.service.ts | 118 ++++++++++++++++++ .../api/src/brevo-api/brevo-api.module.ts | 5 +- .../dto/brevo-api-campaign-statistics.ts | 42 +++++++ .../src/brevo-api/dto/brevo-api-campaign.ts | 25 ++++ .../src/brevo-contact/brevo-contact.module.ts | 3 +- packages/api/src/brevo-module.ts | 6 +- .../api/src/config/brevo-module.config.ts | 14 ++- .../dto/email-campaign-input.factory.ts | 5 +- .../dto/send-test-email-campaign.args.ts | 9 ++ .../email-campaign/email-campaign.module.ts | 24 +++- .../email-campaign/email-campaign.resolver.ts | 90 ++++++++++++- .../email-campaign/email-campaigns.service.ts | 116 ++++++++++++++++- .../entities/email-campaign-entity.factory.ts | 1 + pnpm-lock.yaml | 48 +++++-- 20 files changed, 577 insertions(+), 53 deletions(-) create mode 100644 packages/api/src/brevo-api/brevo-api-campaigns.service.ts create mode 100644 packages/api/src/brevo-api/dto/brevo-api-campaign-statistics.ts create mode 100644 packages/api/src/brevo-api/dto/brevo-api-campaign.ts create mode 100644 packages/api/src/email-campaign/dto/send-test-email-campaign.args.ts diff --git a/README.md b/README.md index 38470461..8a15a66a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ The following variables must be set manually - `BREVO_SENDER_NAME` - `BREVO_SENDER_EMAIL` - `ECG_RTR_LIST_API_KEY` +- `CAMPAIGNS_FRONTEND_URL` +- `CAMPAIGNS_FRONTEND_BASIC_AUTH_USERNAME` +- `CAMPAIGNS_FRONTEND_BASIC_AUTH_PASSWORD` ### Start development processes diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 8ad9f94a..3aa966e0 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -67,8 +67,6 @@ type DamFileLicense { enum LicenseType { ROYALTY_FREE RIGHTS_MANAGED - SUBSCRIPTION - MICRO } """ @@ -120,6 +118,46 @@ type FilenameResponse { isOccupied: Boolean! } +type BrevoApiCampaignStatistics { + """Number of unique clicks for the campaign""" + uniqueClicks: Int! + + """Number of total clicks for the campaign""" + clickers: Int! + + """Number of complaints (Spam reports) for the campaign""" + complaints: Int! + + """Number of delivered emails for the campaign""" + delivered: Int! + + """Number of sent emails for the campaign""" + sent: Int! + + """Number of softbounce for the campaign""" + softBounces: Int! + + """Number of hardbounces for the campaign""" + hardBounces: Int! + + """Number of unique openings for the campaign""" + uniqueViews: Int! + + """Number of unique openings for the campaign""" + trackableViews: Int! + + """ + Rate of recipients without any privacy protection option enabled in their email client, applied to all delivered emails + """ + estimatedViews: Int! + + """Number of unsubscription for the campaign""" + unsubscriptions: Int! + + """Number of openings for the campaign""" + viewed: Int! +} + type Product { id: ID! creatorId: String! @@ -266,6 +304,8 @@ type DamFile { license: DamFileLicense createdAt: DateTime! updatedAt: DateTime! + importSourceId: String + importSourceType: String fileUrl: String! duplicates: [DamFile!]! damPath: String! @@ -358,24 +398,6 @@ 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! @@ -403,6 +425,24 @@ 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! @@ -458,6 +498,7 @@ type Query { 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! + emailCampaignStatistics(id: ID!): BrevoApiCampaignStatistics targetGroup(id: ID!): TargetGroup! targetGroups(scope: EmailCampaignContentScopeInput!, search: String, filter: TargetGroupFilter, sort: [TargetGroupSort!], offset: Int! = 0, limit: Int! = 25): PaginatedTargetGroups! } @@ -667,6 +708,8 @@ type Mutation { createEmailCampaign(scope: EmailCampaignContentScopeInput!, input: EmailCampaignInput!): EmailCampaign! updateEmailCampaign(id: ID!, input: EmailCampaignInput!, lastUpdatedAt: DateTime): EmailCampaign! deleteEmailCampaign(id: ID!): Boolean! + sendEmailCampaignNow(id: ID!): Boolean! + sendEmailCampaignToTestEmails(id: ID!, data: SendTestEmailCampaignArgs!): Boolean! createTargetGroup(scope: EmailCampaignContentScopeInput!, input: TargetGroupInput!): TargetGroup! updateTargetGroup(id: ID!, input: TargetGroupInput!, lastUpdatedAt: DateTime): TargetGroup! deleteTargetGroup(id: ID!): Boolean! @@ -810,6 +853,10 @@ input EmailCampaignInput { """EmailCampaignContent root block input""" scalar EmailCampaignContentBlockInput @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") +input SendTestEmailCampaignArgs { + emails: [String!]! +} + input TargetGroupInput { title: String! filters: BrevoContactFilterAttributesInput diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index 766d035e..33f63baf 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -143,8 +143,17 @@ export class AppModule { ecgRtrList: { apiKey: config.ecgRtrList.apiKey, }, - EmailCampaignContentBlock, - Scope: EmailCampaignContentScope, + emailCampaigns: { + EmailCampaignContentBlock, + Scope: EmailCampaignContentScope, + frontend: { + url: config.campaignsFrontend.url, + basicAuth: { + username: config.campaignsFrontend.basicAuth.username, + password: config.campaignsFrontend.basicAuth.password, + }, + }, + }, }), ], }; diff --git a/demo/api/src/config/config.ts b/demo/api/src/config/config.ts index d226b012..ec6d9f1f 100644 --- a/demo/api/src/config/config.ts +++ b/demo/api/src/config/config.ts @@ -64,6 +64,13 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) { email: envVars.BREVO_SENDER_EMAIL, }, }, + campaignsFrontend: { + url: envVars.CAMPAIGNS_FRONTEND_URL, + basicAuth: { + username: envVars.CAMPAIGNS_FRONTEND_BASIC_AUTH_USERNAME, + password: envVars.CAMPAIGNS_FRONTEND_BASIC_AUTH_PASSWORD, + }, + }, ecgRtrList: { apiKey: envVars.ECG_RTR_LIST_API_KEY, }, diff --git a/demo/api/src/config/environment-variables.ts b/demo/api/src/config/environment-variables.ts index fa11f6b2..77a3750a 100644 --- a/demo/api/src/config/environment-variables.ts +++ b/demo/api/src/config/environment-variables.ts @@ -130,4 +130,13 @@ export class EnvironmentVariables { @IsString() ECG_RTR_LIST_API_KEY: string; + + @IsString() + CAMPAIGNS_FRONTEND_URL: string; + + @IsString() + CAMPAIGNS_FRONTEND_BASIC_AUTH_USERNAME: string; + + @IsString() + CAMPAIGNS_FRONTEND_BASIC_AUTH_PASSWORD: string; } diff --git a/packages/api/package.json b/packages/api/package.json index d72a9c18..87215b70 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,6 +23,8 @@ "test:watch": "jest --watch" }, "dependencies": { + "@nestjs/axios": "^1.0.0", + "@sendinblue/client": "^3.3.1", "node-fetch": "^2.6.1" }, "devDependencies": { @@ -40,11 +42,11 @@ "@nestjs/common": "^9.0.0", "@nestjs/core": "^9.0.0", "@nestjs/graphql": "^10.0.0", - "@sendinblue/client": "^3.3.1", "@types/jest": "^29.5.0", "@types/node-fetch": "^2.5.12", "@types/rimraf": "^3.0.0", "@types/uuid": "^8.3.0", + "axios": "^0.21.0", "class-transformer": "^0.5.0", "class-validator": "^0.13.1", "eslint": "^8.0.0", @@ -72,6 +74,7 @@ "@mikro-orm/nestjs": "^5.0.0", "@mikro-orm/postgresql": "^5.0.4", "@nestjs/common": "^9.0.0", + "axios": "^0.21.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.0.0" } diff --git a/packages/api/src/brevo-api/brevo-api-campaigns.service.ts b/packages/api/src/brevo-api/brevo-api-campaigns.service.ts new file mode 100644 index 00000000..518f3178 --- /dev/null +++ b/packages/api/src/brevo-api/brevo-api-campaigns.service.ts @@ -0,0 +1,118 @@ +import { Inject, Injectable } from "@nestjs/common"; +import * as SibApiV3Sdk from "@sendinblue/client"; + +import { BrevoModuleConfig } from "../config/brevo-module.config"; +import { BREVO_MODULE_CONFIG } from "../config/brevo-module.constants"; +import { EmailCampaignInterface } from "../email-campaign/entities/email-campaign-entity.factory"; +import { SendingState } from "../email-campaign/sending-state.enum"; +import { BrevoApiCampaign } from "./dto/brevo-api-campaign"; +import { BrevoApiCampaignStatistics } from "./dto/brevo-api-campaign-statistics"; + +@Injectable() +export class BrevoApiCampaignsService { + private readonly campaignsApi: SibApiV3Sdk.EmailCampaignsApi; + + constructor(@Inject(BREVO_MODULE_CONFIG) private readonly config: BrevoModuleConfig) { + this.campaignsApi = new SibApiV3Sdk.EmailCampaignsApi(); + this.campaignsApi.setApiKey(SibApiV3Sdk.EmailCampaignsApiApiKeys.apiKey, config.brevo.apiKey); + } + + public getSendingInformationFromBrevoCampaign(campaign: BrevoApiCampaign): SendingState { + if (campaign.status === "sent") { + return SendingState.SENT; + } else if (campaign.status === "queued" || campaign.status === "in_process") { + return SendingState.SCHEDULED; + } + + return SendingState.DRAFT; + } + + public async createBrevoCampaign(campaign: EmailCampaignInterface, htmlContent: string, scheduledAt?: Date): Promise { + const emailCampaign = { + name: campaign.title, + subject: campaign.subject, + sender: { name: this.config.brevo.sender.name, email: this.config.brevo.sender.email }, + // TODO: add correct list after contact list/target groups are implemented + recipients: { listIds: [2] }, + htmlContent, + scheduledAt: scheduledAt?.toISOString(), + }; + + const data = await this.campaignsApi.createEmailCampaign(emailCampaign); + return data.body.id; + } + + public async updateBrevoCampaign(id: number, campaign: EmailCampaignInterface, htmlContent: string, scheduledAt?: Date): Promise { + const emailCampaign = { + name: campaign.title, + subject: campaign.subject, + sender: { name: this.config.brevo.sender.name, email: this.config.brevo.sender.email }, + // TODO: add correct list after contact list/target groups are implemented + recipients: { listIds: [2] }, + htmlContent, + scheduledAt: scheduledAt?.toISOString(), + }; + + const result = await this.campaignsApi.updateEmailCampaign(id, emailCampaign); + return result.response.statusCode === 204; + } + + public async sendBrevoCampaign(id: number): Promise { + const result = await this.campaignsApi.sendEmailCampaignNow(id); + return result.response.statusCode === 204; + } + + public async updateBrevoCampaignStatus(id: number, updated_status: SibApiV3Sdk.UpdateCampaignStatus.StatusEnum): Promise { + const status = new SibApiV3Sdk.UpdateCampaignStatus(); + status.status = updated_status; + const result = await this.campaignsApi.updateCampaignStatus(id, status); + return result.response.statusCode === 204; + } + + public async sendTestEmail(id: number, emails: string[]): Promise { + const result = await this.campaignsApi.sendTestEmail(id, { emailTo: emails }); + return result.response.statusCode === 204; + } + + public async loadBrevoCampaignsByIds(ids: number[]): Promise { + const campaigns = []; + for await (const campaign of await this.getCampaignsResponse(ids)) { + campaigns.push(campaign); + } + + return campaigns; + } + + public async loadBrevoCampaignById(id: number): Promise { + const response = await this.campaignsApi.getEmailCampaign(id); + + // wrong type in brevo library -> needs to be cast to unknown first + return response.body as unknown as BrevoApiCampaign; + } + + public async loadBrevoCampaignStatisticsById(id: number): Promise { + const campaign = await this.campaignsApi.getEmailCampaign(id); + + return campaign.body.statistics.campaignStats[0]; + } + + private async *getCampaignsResponse( + ids: number[], + status?: "suspended" | "archive" | "sent" | "queued" | "draft" | "inProcess", + ): AsyncGenerator { + let offset = 0; + const limit = 100; + + while (true) { + const campaignsResponse = await this.campaignsApi.getEmailCampaigns(undefined, status, undefined, undefined, undefined, limit, offset); + const campaignArray = (campaignsResponse.body.campaigns ?? []).filter((item) => ids.includes(item.id)); + + if (campaignArray.length === 0) { + break; + } + yield* campaignArray; + + offset += limit; + } + } +} diff --git a/packages/api/src/brevo-api/brevo-api.module.ts b/packages/api/src/brevo-api/brevo-api.module.ts index 9d112fea..11159330 100644 --- a/packages/api/src/brevo-api/brevo-api.module.ts +++ b/packages/api/src/brevo-api/brevo-api.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "../config/config.module"; +import { BrevoApiCampaignsService } from "./brevo-api-campaigns.service"; import { BrevoApiContactsService } from "./brevo-api-contact.service"; @Module({ imports: [ConfigModule], - providers: [BrevoApiContactsService], - exports: [BrevoApiContactsService], + providers: [BrevoApiContactsService, BrevoApiCampaignsService], + exports: [BrevoApiContactsService, BrevoApiCampaignsService], }) export class BrevoApiModule {} diff --git a/packages/api/src/brevo-api/dto/brevo-api-campaign-statistics.ts b/packages/api/src/brevo-api/dto/brevo-api-campaign-statistics.ts new file mode 100644 index 00000000..e31aa6f8 --- /dev/null +++ b/packages/api/src/brevo-api/dto/brevo-api-campaign-statistics.ts @@ -0,0 +1,42 @@ +import { Field, Int, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class BrevoApiCampaignStatistics { + @Field(() => Int, { description: "Number of unique clicks for the campaign" }) + uniqueClicks: number; + + @Field(() => Int, { description: "Number of total clicks for the campaign" }) + clickers: number; + + @Field(() => Int, { description: "Number of complaints (Spam reports) for the campaign" }) + complaints: number; + + @Field(() => Int, { description: "Number of delivered emails for the campaign" }) + delivered: number; + + @Field(() => Int, { description: "Number of sent emails for the campaign" }) + sent: number; + + @Field(() => Int, { description: "Number of softbounce for the campaign" }) + softBounces: number; + + @Field(() => Int, { description: "Number of hardbounces for the campaign" }) + hardBounces: number; + + @Field(() => Int, { description: "Number of unique openings for the campaign" }) + uniqueViews: number; + + @Field(() => Int, { description: "Number of unique openings for the campaign" }) + trackableViews: number; + + @Field(() => Int, { + description: "Rate of recipients without any privacy protection option enabled in their email client, applied to all delivered emails", + }) + estimatedViews: number; + + @Field(() => Int, { description: "Number of unsubscription for the campaign" }) + unsubscriptions: number; + + @Field(() => Int, { description: "Number of openings for the campaign" }) + viewed: number; +} diff --git a/packages/api/src/brevo-api/dto/brevo-api-campaign.ts b/packages/api/src/brevo-api/dto/brevo-api-campaign.ts new file mode 100644 index 00000000..7566ecff --- /dev/null +++ b/packages/api/src/brevo-api/dto/brevo-api-campaign.ts @@ -0,0 +1,25 @@ +export interface BrevoApiCampaign { + id: number; + name: string; + subject?: string; + type: string; + status: "draft" | "sent" | "archive" | "queued" | "suspended" | "in_process"; + statistics: { + globalStats: { + uniqueClicks: number; + clickers: number; + complaints: number; + delivered: number; + sent: number; + softBounces: number; + hardBounces: number; + uniqueViews: number; + trackableViews: number; + estimatedViews: number; + unsubscriptions: number; + viewed: number; + }; + }; + sentDate?: string; + scheduledAt?: string; +} diff --git a/packages/api/src/brevo-contact/brevo-contact.module.ts b/packages/api/src/brevo-contact/brevo-contact.module.ts index cb472a08..d7bdf388 100644 --- a/packages/api/src/brevo-contact/brevo-contact.module.ts +++ b/packages/api/src/brevo-contact/brevo-contact.module.ts @@ -2,7 +2,6 @@ import { DynamicModule, Module, Type } from "@nestjs/common"; import { BrevoApiModule } from "../brevo-api/brevo-api.module"; import { ConfigModule } from "../config/config.module"; -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"; @@ -26,7 +25,7 @@ export class BrevoContactModule { return { module: BrevoContactModule, - imports: [BrevoApiModule, ConfigModule, TargetGroupModule.register({ Scope, BrevoFilterAttributes })], + imports: [BrevoApiModule, ConfigModule], providers: [BrevoContactsService, BrevoContactResolver, EcgRtrListService, IsValidRedirectURLConstraint], }; } diff --git a/packages/api/src/brevo-module.ts b/packages/api/src/brevo-module.ts index e1d8150a..25209ad8 100644 --- a/packages/api/src/brevo-module.ts +++ b/packages/api/src/brevo-module.ts @@ -18,13 +18,13 @@ export class BrevoModule { BrevoContactModule.register({ BrevoContactAttributes: config.brevo.BrevoContactAttributes, BrevoFilterAttributes: config.brevo.BrevoContactFilterAttributes, - Scope: config.Scope, + Scope: config.emailCampaigns.Scope, }), EmailCampaignModule.register(config), - TargetGroupModule, + TargetGroupModule.register({ Scope: config.emailCampaigns.Scope, BrevoFilterAttributes: config.brevo.BrevoContactFilterAttributes }), ConfigModule.forRoot(config), ], - exports: [], + exports: [TargetGroupModule], }; } } diff --git a/packages/api/src/config/brevo-module.config.ts b/packages/api/src/config/brevo-module.config.ts index 9a768d62..fc5829ec 100644 --- a/packages/api/src/config/brevo-module.config.ts +++ b/packages/api/src/config/brevo-module.config.ts @@ -19,6 +19,16 @@ export interface BrevoModuleConfig { ecgRtrList: { apiKey: string; }; - Scope: Type; - EmailCampaignContentBlock: Block; + + emailCampaigns: { + Scope: Type; + EmailCampaignContentBlock: Block; + frontend: { + url: string; + basicAuth: { + username: string; + password: string; + }; + }; + }; } diff --git a/packages/api/src/email-campaign/dto/email-campaign-input.factory.ts b/packages/api/src/email-campaign/dto/email-campaign-input.factory.ts index 8ad9f014..14777017 100644 --- a/packages/api/src/email-campaign/dto/email-campaign-input.factory.ts +++ b/packages/api/src/email-campaign/dto/email-campaign-input.factory.ts @@ -3,7 +3,7 @@ import { IsUndefinable, RootBlockInputScalar } from "@comet/cms-api"; import { Type } from "@nestjs/common"; import { Field, InputType } from "@nestjs/graphql"; import { Transform } from "class-transformer"; -import { IsDate, IsNotEmpty, IsString, ValidateNested } from "class-validator"; +import { IsDate, IsNotEmpty, IsString, MinDate, ValidateNested } from "class-validator"; export interface EmailCampaignInputInterface { title: string; @@ -28,7 +28,8 @@ export class EmailCampaignInputFactory { @IsUndefinable() @IsDate() - @Field({ nullable: true }) + @MinDate(new Date()) + @Field(() => Date, { nullable: true }) scheduledAt?: Date; @Field(() => RootBlockInputScalar(EmailCampaignContentBlock)) diff --git a/packages/api/src/email-campaign/dto/send-test-email-campaign.args.ts b/packages/api/src/email-campaign/dto/send-test-email-campaign.args.ts new file mode 100644 index 00000000..f672e3e5 --- /dev/null +++ b/packages/api/src/email-campaign/dto/send-test-email-campaign.args.ts @@ -0,0 +1,9 @@ +import { Field, InputType } from "@nestjs/graphql"; +import { IsEmail } from "class-validator"; + +@InputType() +export class SendTestEmailCampaignArgs { + @Field(() => [String]) + @IsEmail({}, { each: true }) + emails: string[]; +} diff --git a/packages/api/src/email-campaign/email-campaign.module.ts b/packages/api/src/email-campaign/email-campaign.module.ts index e3e4a9f7..f5d2f51e 100644 --- a/packages/api/src/email-campaign/email-campaign.module.ts +++ b/packages/api/src/email-campaign/email-campaign.module.ts @@ -1,7 +1,9 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { HttpModule } from "@nestjs/axios"; import { DynamicModule, Module } from "@nestjs/common"; -import { BrevoModule } from "../brevo-module"; +import { BrevoApiModule } from "../brevo-api/brevo-api.module"; +import { EcgRtrListService } from "../brevo-contact/ecg-rtr-list/ecg-rtr-list.service"; import { BrevoModuleConfig } from "../config/brevo-module.config"; import { ConfigModule } from "../config/config.module"; import { EmailCampaignInputFactory } from "./dto/email-campaign-input.factory"; @@ -12,14 +14,24 @@ import { EmailCampaignEntityFactory } from "./entities/email-campaign-entity.fac @Module({}) export class EmailCampaignModule { static register(config: BrevoModuleConfig): DynamicModule { - const EmailCampaign = EmailCampaignEntityFactory.create({ Scope: config.Scope, EmailCampaignContentBlock: config.EmailCampaignContentBlock }); - const EmailCampaignInput = EmailCampaignInputFactory.create({ EmailCampaignContentBlock: config.EmailCampaignContentBlock }); - const EmailCampaignsResolver = createEmailCampaignsResolver({ EmailCampaign, EmailCampaignInput, Scope: config.Scope }); + const EmailCampaign = EmailCampaignEntityFactory.create({ + Scope: config.emailCampaigns.Scope, + EmailCampaignContentBlock: config.emailCampaigns.EmailCampaignContentBlock, + }); + const EmailCampaignInput = EmailCampaignInputFactory.create({ EmailCampaignContentBlock: config.emailCampaigns.EmailCampaignContentBlock }); + const EmailCampaignsResolver = createEmailCampaignsResolver({ EmailCampaign, EmailCampaignInput, Scope: config.emailCampaigns.Scope }); return { module: EmailCampaignModule, - imports: [ConfigModule, BrevoModule, MikroOrmModule.forFeature([EmailCampaign])], - providers: [EmailCampaignsResolver, EmailCampaignsService], + imports: [ + ConfigModule, + BrevoApiModule, + HttpModule.register({ + timeout: 5000, + }), + MikroOrmModule.forFeature([EmailCampaign]), + ], + providers: [EmailCampaignsResolver, EmailCampaignsService, EcgRtrListService], }; } } diff --git a/packages/api/src/email-campaign/email-campaign.resolver.ts b/packages/api/src/email-campaign/email-campaign.resolver.ts index c0db69a6..6c2daa9e 100644 --- a/packages/api/src/email-campaign/email-campaign.resolver.ts +++ b/packages/api/src/email-campaign/email-campaign.resolver.ts @@ -2,14 +2,19 @@ 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 { EmailCampaignScopeInterface } from "src/types"; +import { Args, ArgsType, ID, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { BrevoApiCampaignsService } from "../brevo-api/brevo-api-campaigns.service"; +import { BrevoApiCampaignStatistics } from "../brevo-api/dto/brevo-api-campaign-statistics"; +import { EcgRtrListService } from "../brevo-contact/ecg-rtr-list/ecg-rtr-list.service"; +import { EmailCampaignScopeInterface } from "../types"; import { DynamicDtoValidationPipe } from "../validation/dynamic-dto-validation.pipe"; import { EmailCampaignArgsFactory } from "./dto/email-campaign-args.factory"; import { EmailCampaignInputInterface } from "./dto/email-campaign-input.factory"; +import { SendTestEmailCampaignArgs } from "./dto/send-test-email-campaign.args"; import { EmailCampaignsService } from "./email-campaigns.service"; import { EmailCampaignInterface } from "./entities/email-campaign-entity.factory"; +import { SendingState } from "./sending-state.enum"; export function createEmailCampaignsResolver({ EmailCampaign, @@ -30,6 +35,8 @@ export function createEmailCampaignsResolver({ class EmailCampaignsResolver { constructor( private readonly campaignsService: EmailCampaignsService, + private readonly brevoApiCampaignsService: BrevoApiCampaignsService, + private readonly ecgRtrListService: EcgRtrListService, private readonly entityManager: EntityManager, @InjectRepository("EmailCampaign") private readonly repository: EntityRepository, ) {} @@ -57,7 +64,10 @@ export function createEmailCampaignsResolver({ } const [entities, totalCount] = await this.repository.findAndCount(where, options); - return new PaginatedEmailCampaigns(entities, totalCount); + + const emailCampaigns = this.campaignsService.loadEmailCampaignSendingStatesForEmailCampaigns(entities); + + return new PaginatedEmailCampaigns(emailCampaigns, totalCount); } @Mutation(() => EmailCampaign) @@ -74,6 +84,10 @@ export function createEmailCampaignsResolver({ await this.entityManager.flush(); + if (input.scheduledAt) { + await this.campaignsService.saveEmailCampaignInBrevo(campaign.id, input.scheduledAt); + } + return campaign; } @@ -93,10 +107,32 @@ export function createEmailCampaignsResolver({ wrap(campaign).assign({ ...input, content: input.content.transformToBlockData(), + scheduledAt: input.scheduledAt ?? null, }); await this.entityManager.flush(); + let hasScheduleRemoved = false; + + if (campaign.brevoId) { + const brevoEmailCampaign = await this.brevoApiCampaignsService.loadBrevoCampaignById(campaign.brevoId); + const sendingState = this.brevoApiCampaignsService.getSendingInformationFromBrevoCampaign(brevoEmailCampaign); + + if (sendingState === SendingState.SENT) { + throw new Error("Cannot update email campaign that has already been sent."); + } + + hasScheduleRemoved = input.scheduledAt == null && brevoEmailCampaign.scheduledAt !== null; + + if (hasScheduleRemoved && !(sendingState === SendingState.DRAFT)) { + await this.campaignsService.suspendEmailCampaign(campaign.brevoId); + } + } + + if (!hasScheduleRemoved && input.scheduledAt) { + await this.campaignsService.saveEmailCampaignInBrevo(campaign.id, input.scheduledAt); + } + return campaign; } @@ -104,10 +140,58 @@ export function createEmailCampaignsResolver({ @SubjectEntity(EmailCampaign) async deleteEmailCampaign(@Args("id", { type: () => ID }) id: string): Promise { const campaign = await this.repository.findOneOrFail(id); + + if (campaign.brevoId) { + throw new Error("Cannot delete campaign that has already been scheduled once before."); + } + await this.entityManager.remove(campaign); await this.entityManager.flush(); return true; } + + @Mutation(() => Boolean) + async sendEmailCampaignNow(@Args("id", { type: () => ID }) id: string): Promise { + return this.campaignsService.sendEmailCampaignNow(id); + } + + @Mutation(() => Boolean) + async sendEmailCampaignToTestEmails( + @Args("id", { type: () => ID }) id: string, + @Args("data", { type: () => SendTestEmailCampaignArgs }) data: SendTestEmailCampaignArgs, + ): Promise { + const campaign = await this.campaignsService.saveEmailCampaignInBrevo(id); + + const containedEcgRtrListEmails = await this.ecgRtrListService.getContainedEcgRtrListEmails(data.emails); + const emailsNotInEcgRtrList = data.emails.filter((email) => !containedEcgRtrListEmails.includes(email)); + + if (campaign.brevoId) { + return this.brevoApiCampaignsService.sendTestEmail(campaign.brevoId, emailsNotInEcgRtrList); + } + + return false; + } + + @Query(() => BrevoApiCampaignStatistics, { nullable: true }) + async emailCampaignStatistics(@Args("id", { type: () => ID }) id: string): Promise { + const campaign = await this.repository.findOneOrFail(id); + + return campaign.brevoId ? this.brevoApiCampaignsService.loadBrevoCampaignStatisticsById(campaign.brevoId) : null; + } + + @ResolveField(() => SendingState) + async sendingState(@Parent() campaign: EmailCampaignInterface): Promise { + if (campaign.sendingState) { + return campaign.sendingState; + } + + if (campaign.brevoId) { + const brevoCampaign = await this.brevoApiCampaignsService.loadBrevoCampaignById(campaign.brevoId); + return this.brevoApiCampaignsService.getSendingInformationFromBrevoCampaign(brevoCampaign); + } + + return SendingState.DRAFT; + } } return EmailCampaignsResolver; diff --git a/packages/api/src/email-campaign/email-campaigns.service.ts b/packages/api/src/email-campaign/email-campaigns.service.ts index dca41c1a..5e37d43c 100644 --- a/packages/api/src/email-campaign/email-campaigns.service.ts +++ b/packages/api/src/email-campaign/email-campaigns.service.ts @@ -1,12 +1,31 @@ -import { filtersToMikroOrmQuery, searchToMikroOrmQuery } from "@comet/cms-api"; -import { ObjectQuery } from "@mikro-orm/core"; -import { Injectable } from "@nestjs/common"; +import { BlocksTransformerService, filtersToMikroOrmQuery, searchToMikroOrmQuery } from "@comet/cms-api"; +import { EntityManager, EntityRepository, ObjectQuery, wrap } from "@mikro-orm/core"; +import { InjectRepository } from "@mikro-orm/nestjs"; +import { HttpService } from "@nestjs/axios"; +import { Inject, Injectable } from "@nestjs/common"; +import { UpdateCampaignStatus } from "@sendinblue/client"; +import { BrevoApiCampaignsService } from "../brevo-api/brevo-api-campaigns.service"; +import { BrevoApiContactsService } from "../brevo-api/brevo-api-contact.service"; +import { EcgRtrListService } from "../brevo-contact/ecg-rtr-list/ecg-rtr-list.service"; +import { BrevoModuleConfig } from "../config/brevo-module.config"; +import { BREVO_MODULE_CONFIG } from "../config/brevo-module.constants"; import { EmailCampaignFilter } from "./dto/email-campaign.filter"; import { EmailCampaignInterface } from "./entities/email-campaign-entity.factory"; @Injectable() export class EmailCampaignsService { + constructor( + @Inject(BREVO_MODULE_CONFIG) private readonly config: BrevoModuleConfig, + @InjectRepository("EmailCampaign") private readonly repository: EntityRepository, + private readonly httpService: HttpService, + private readonly brevoApiCampaignService: BrevoApiCampaignsService, + private readonly brevoApiContactsService: BrevoApiContactsService, + private readonly entityManager: EntityManager, + private readonly ecgRtrListService: EcgRtrListService, + private readonly blockTransformerService: BlocksTransformerService, + ) {} + getFindCondition(options: { search?: string; filter?: EmailCampaignFilter }): ObjectQuery { const andFilters = []; @@ -20,4 +39,95 @@ export class EmailCampaignsService { return andFilters.length > 0 ? { $and: andFilters } : {}; } + + async saveEmailCampaignInBrevo(id: string, scheduledAt?: Date): Promise { + const campaign = await this.repository.findOneOrFail(id); + + const content = await this.blockTransformerService.transformToPlain(campaign.content); + + const { data: htmlContent, status } = await this.httpService.axiosRef.post( + this.config.emailCampaigns.frontend.url, + { title: campaign.title, content, scope: campaign.scope }, + { + headers: { "Content-Type": "application/json" }, + auth: { + username: this.config.emailCampaigns.frontend.basicAuth.username, + password: this.config.emailCampaigns.frontend.basicAuth.password, + }, + }, + ); + + if (!htmlContent || status !== 200) { + throw new Error("Could not generate campaign content"); + } + + let brevoId = campaign.brevoId; + if (!brevoId) { + brevoId = await this.brevoApiCampaignService.createBrevoCampaign(campaign, htmlContent, scheduledAt); + + wrap(campaign).assign({ brevoId }); + + await this.entityManager.flush(); + } else { + await this.brevoApiCampaignService.updateBrevoCampaign(brevoId, campaign, htmlContent, scheduledAt); + } + + return campaign; + } + + async suspendEmailCampaign(brevoId: number): Promise { + return this.brevoApiCampaignService.updateBrevoCampaignStatus(brevoId, UpdateCampaignStatus.StatusEnum.Suspended); + } + + public async loadEmailCampaignSendingStatesForEmailCampaigns(campaigns: EmailCampaignInterface[]): Promise { + const brevoIds = campaigns.map((campaign) => campaign.brevoId).filter((campaign) => campaign) as number[]; + + if (brevoIds.length > 0) { + const brevoCampaigns = await this.brevoApiCampaignService.loadBrevoCampaignsByIds(brevoIds); + + for (const brevoCampaign of brevoCampaigns) { + const sendingState = this.brevoApiCampaignService.getSendingInformationFromBrevoCampaign(brevoCampaign); + + const campaign = campaigns.find((campaign) => campaign.brevoId === brevoCampaign.id); + if (campaign) { + campaign.sendingState = sendingState; + } + } + } + + return campaigns; + } + + public async sendEmailCampaignNow(id: string): Promise { + const campaign = await this.saveEmailCampaignInBrevo(id); + + // TODO: add correct list of contact list / target groups after they are implemented + const contactList = { + brevoId: 2, + }; + + if (contactList?.brevoId) { + let currentOffset = 0; + let totalContacts = 0; + const limit = 50; + do { + const [contacts, total] = await this.brevoApiContactsService.findContactsByListId(contactList.brevoId, limit, currentOffset); + const emails = contacts.map((contact) => contact.email); + const containedEmails = await this.ecgRtrListService.getContainedEcgRtrListEmails(emails); + + if (containedEmails.length > 0) { + await this.brevoApiContactsService.blacklistMultipleContacts(containedEmails); + } + + if (campaign.brevoId) { + return this.brevoApiCampaignService.sendBrevoCampaign(campaign.brevoId); + } + + currentOffset += limit; + totalContacts = total; + } while (currentOffset < totalContacts); + } + + return campaign.brevoId ? this.brevoApiCampaignService.sendBrevoCampaign(campaign.brevoId) : false; + } } diff --git a/packages/api/src/email-campaign/entities/email-campaign-entity.factory.ts b/packages/api/src/email-campaign/entities/email-campaign-entity.factory.ts index c93abb64..2f0e3301 100644 --- a/packages/api/src/email-campaign/entities/email-campaign-entity.factory.ts +++ b/packages/api/src/email-campaign/entities/email-campaign-entity.factory.ts @@ -19,6 +19,7 @@ export interface EmailCampaignInterface { updatedAt: Date; content: BlockDataInterface; scope: EmailCampaignScopeInterface; + sendingState: SendingState; } export class EmailCampaignEntityFactory { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aa5e4e1..b15c8166 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,12 @@ importers: packages/api: dependencies: + '@nestjs/axios': + specifier: ^1.0.0 + version: 1.0.1(@nestjs/common@9.4.3)(reflect-metadata@0.1.14)(rxjs@7.8.1) + '@sendinblue/client': + specifier: ^3.3.1 + version: 3.3.1 node-fetch: specifier: ^2.6.1 version: 2.7.0 @@ -726,9 +732,6 @@ importers: '@nestjs/graphql': specifier: ^10.0.0 version: 10.2.1(@nestjs/common@9.4.3)(@nestjs/core@9.4.3)(class-transformer@0.5.1)(class-validator@0.13.2)(graphql@15.8.0)(reflect-metadata@0.1.14) - '@sendinblue/client': - specifier: ^3.3.1 - version: 3.3.1 '@types/jest': specifier: ^29.5.0 version: 29.5.11 @@ -741,6 +744,9 @@ importers: '@types/uuid': specifier: ^8.3.0 version: 8.3.4 + axios: + specifier: ^0.21.0 + version: 0.21.4 class-transformer: specifier: ^0.5.0 version: 0.5.1 @@ -6206,6 +6212,21 @@ packages: tslib: 2.5.0 dev: false + /@nestjs/axios@1.0.1(@nestjs/common@9.4.3)(reflect-metadata@0.1.14)(rxjs@7.8.1): + resolution: {integrity: sha512-TpoZM/0ZJ9xiC04qkRDFod93LCZ12TQARRU3ejDvBK2E8emvzM4HThOs5ePklVxce4Q1ZsnrIWqnImvoDmJYnQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 + reflect-metadata: ^0.1.12 + rxjs: ^6.0.0 || ^7.0.0 + dependencies: + '@nestjs/common': 9.4.3(class-transformer@0.5.1)(class-validator@0.13.2)(reflect-metadata@0.1.14)(rxjs@7.8.1) + axios: 1.2.1 + reflect-metadata: 0.1.14 + rxjs: 7.8.1 + transitivePeerDependencies: + - debug + dev: false + /@nestjs/cli@9.5.0: resolution: {integrity: sha512-Z7q+3vNsQSG2d2r2Hl/OOj5EpfjVx3OfnJ9+KuAsOdw1sKLm7+Zc6KbhMFTd/eIvfx82ww3Nk72xdmfPYCulWA==} engines: {node: '>= 12.9.0'} @@ -7686,7 +7707,7 @@ packages: bluebird: 3.7.2 lodash: 4.17.21 request: 2.88.2 - dev: true + dev: false /@sideway/address@4.1.4: resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} @@ -8223,7 +8244,7 @@ packages: /@types/bluebird@3.5.42: resolution: {integrity: sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==} - dev: true + dev: false /@types/body-parser@1.19.2: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} @@ -9848,7 +9869,6 @@ packages: follow-redirects: 1.15.5 transitivePeerDependencies: - debug - dev: false /axios@0.25.0: resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==} @@ -9858,6 +9878,16 @@ packages: - debug dev: true + /axios@1.2.1: + resolution: {integrity: sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==} + dependencies: + follow-redirects: 1.15.5 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query@3.2.1: resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} dependencies: @@ -10196,7 +10226,7 @@ packages: /bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - dev: true + dev: false /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} @@ -17434,6 +17464,10 @@ packages: forwarded: 0.2.0 ipaddr.js: 1.9.1 + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /proxyquire@2.1.3: resolution: {integrity: sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==} dependencies: