diff --git a/.changeset/yellow-toes-tickle.md b/.changeset/yellow-toes-tickle.md new file mode 100644 index 00000000..4237fbb3 --- /dev/null +++ b/.changeset/yellow-toes-tickle.md @@ -0,0 +1,20 @@ +--- +"@comet/brevo-admin": major +"@comet/brevo-api": minor +--- + +A required brevo config page must now be generated with `createBrevoConfigPage`. +All necessary brevo configuration (for each scope) must be configured within this page for emails campaigns to be sent. + +```diff ++ const BrevoConfigPage = createBrevoConfigPage({ ++ scopeParts: ["domain", "language"], ++ }); +``` + +Env vars containing the brevo sender information can be removed. + +```diff +- BREVO_SENDER_NAME=senderName +- BREVO_SENDER_EMAIL=senderEmail +``` diff --git a/README.md b/README.md index 1cbf6858..4eb31d1d 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,15 @@ The following variables must be set manually - `BREVO_API_KEY` - `BREVO_DOUBLE_OPT_IN_TEMPLATE_ID` -- `BREVO_SENDER_NAME` -- `BREVO_SENDER_EMAIL` - `ECG_RTR_LIST_API_KEY` - `CAMPAIGN_BASIC_AUTH_USERNAME` - `CAMPAIGN_BASIC_AUTH_PASSWORD` +### Configure brevo sender in the demo admin brevo config page + +- Brevo sender name +- Brevo sender mail + ### Start development processes [dev-process-manager](https://github.com/vivid-planet/dev-process-manager) is used for local development. diff --git a/demo/admin/src/Routes.tsx b/demo/admin/src/Routes.tsx index b6dda603..bf273147 100644 --- a/demo/admin/src/Routes.tsx +++ b/demo/admin/src/Routes.tsx @@ -1,6 +1,6 @@ import { MasterLayout, RouteWithErrorBoundary } from "@comet/admin"; import { Domain } from "@comet/admin-icons"; -import { createBrevoContactsPage, createEmailCampaignsPage, createTargetGroupsPage } from "@comet/brevo-admin"; +import { createBrevoConfigPage, createBrevoContactsPage, createEmailCampaignsPage, createTargetGroupsPage } from "@comet/brevo-admin"; import { ContentScopeIndicator, createRedirectsPage, DamPage, PagesPage, PublisherPage, SitePreview } from "@comet/cms-admin"; import { getBrevoContactConfig } from "@src/common/brevoModuleConfig/brevoContactsPageAttributesConfig"; import { config } from "@src/config"; @@ -50,6 +50,10 @@ export const Routes: React.FC = () => { previewUrl: `${config.campaignUrl}/preview`, }); + const BrevoConfigPage = createBrevoConfigPage({ + scopeParts: ["domain", "language"], + }); + return ( {({ match }) => ( @@ -97,6 +101,7 @@ export const Routes: React.FC = () => { + diff --git a/demo/admin/src/common/MasterMenu.tsx b/demo/admin/src/common/MasterMenu.tsx index f71f8e97..1d175b63 100644 --- a/demo/admin/src/common/MasterMenu.tsx +++ b/demo/admin/src/common/MasterMenu.tsx @@ -48,6 +48,10 @@ export const MasterMenu: React.FC = () => { primary={intl.formatMessage({ id: "menu.newsletter.emailCampaigns", defaultMessage: "Email campaigns" })} to={`${match.url}/newsletter/email-campaigns`} /> + }> { + this.addSql( + 'create table "BrevoConfig" ("id" uuid not null, "createdAt" timestamp with time zone not null, "updatedAt" timestamp with time zone not null, "scope_domain" text not null, "scope_language" text not null, "senderMail" text not null, "senderName" text not null, constraint "BrevoConfig_pkey" primary key ("id"));', + ); + } +} diff --git a/packages/admin/src/brevoConfiguration/BrevoConfigForm.gql.ts b/packages/admin/src/brevoConfiguration/BrevoConfigForm.gql.ts new file mode 100644 index 00000000..661f5a72 --- /dev/null +++ b/packages/admin/src/brevoConfiguration/BrevoConfigForm.gql.ts @@ -0,0 +1,59 @@ +import { gql } from "@apollo/client"; + +export const brevoConfigFormFragment = gql` + fragment BrevoConfigForm on BrevoConfig { + senderMail + senderName + } +`; + +export const brevoConfigFormQuery = gql` + query BrevoConfigForm($scope: EmailCampaignContentScopeInput!) { + brevoConfig(scope: $scope) { + id + updatedAt + ...BrevoConfigForm + } + } + ${brevoConfigFormFragment} +`; + +export const brevoConfigFormCheckForChangesQuery = gql` + query BrevoConfigFormCheckForChanges($scope: EmailCampaignContentScopeInput!) { + brevoConfig(scope: $scope) { + updatedAt + } + } +`; + +export const createBrevoConfigMutation = gql` + mutation CreateBrevoConfig($scope: EmailCampaignContentScopeInput!, $input: BrevoConfigInput!) { + createBrevoConfig(scope: $scope, input: $input) { + id + updatedAt + ...BrevoConfigForm + } + } + ${brevoConfigFormFragment} +`; + +export const updateBrevoConfigMutation = gql` + mutation UpdateBrevoConfig($id: ID!, $input: BrevoConfigUpdateInput!, $lastUpdatedAt: DateTime) { + updateBrevoConfig(id: $id, input: $input, lastUpdatedAt: $lastUpdatedAt) { + id + updatedAt + ...BrevoConfigForm + } + } + ${brevoConfigFormFragment} +`; + +export const sendersSelectQuery = gql` + query SendersSelect { + senders { + id + name + email + } + } +`; diff --git a/packages/admin/src/brevoConfiguration/BrevoConfigForm.tsx b/packages/admin/src/brevoConfiguration/BrevoConfigForm.tsx new file mode 100644 index 00000000..c077067c --- /dev/null +++ b/packages/admin/src/brevoConfiguration/BrevoConfigForm.tsx @@ -0,0 +1,182 @@ +import { gql, useApolloClient, useQuery } from "@apollo/client"; +import { + Field, + FinalForm, + FinalFormAutocomplete, + FinalFormSaveSplitButton, + FinalFormSubmitEvent, + Loading, + MainContent, + Toolbar, + ToolbarActions, + ToolbarFillSpace, + ToolbarTitleItem, + useFormApiRef, + useStackSwitchApi, +} from "@comet/admin"; +import { ContentScopeInterface, EditPageLayout, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin"; +import { FormApi } from "final-form"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { brevoConfigFormQuery, createBrevoConfigMutation, sendersSelectQuery, updateBrevoConfigMutation } from "./BrevoConfigForm.gql"; +import { + GQLBrevoConfigFormQuery, + GQLBrevoConfigFormQueryVariables, + GQLCreateBrevoConfigMutation, + GQLCreateBrevoConfigMutationVariables, + GQLSendersSelectQuery, + GQLSendersSelectQueryVariables, + GQLUpdateBrevoConfigMutation, + GQLUpdateBrevoConfigMutationVariables, +} from "./BrevoConfigForm.gql.generated"; + +interface Option { + value: string; + label: string; +} +type FormValues = { + sender: Option; +}; + +interface FormProps { + scope: ContentScopeInterface; +} + +export function BrevoConfigForm({ scope }: FormProps): React.ReactElement { + const client = useApolloClient(); + const formApiRef = useFormApiRef(); + const stackSwitchApi = useStackSwitchApi(); + + const { data, error, loading, refetch } = useQuery(brevoConfigFormQuery, { + variables: { scope }, + }); + + const { + data: sendersData, + error: senderError, + loading: senderLoading, + } = useQuery(sendersSelectQuery); + + const senderOptions = + sendersData?.senders?.map((sender) => ({ + value: sender.email, + label: `${sender.name} (${sender.email})`, + })) ?? []; + + const mode = data?.brevoConfig?.id ? "edit" : "add"; + + const initialValues = React.useMemo>(() => { + const sender = sendersData?.senders?.find((s) => s.email === data?.brevoConfig?.senderMail && s.name === data?.brevoConfig?.senderName); + return sender + ? { + sender: { + value: sender.id, + label: `${sender.name} (${sender.email})`, + }, + } + : {}; + }, [data?.brevoConfig?.senderMail, data?.brevoConfig?.senderName, sendersData?.senders]); + + const saveConflict = useFormSaveConflict({ + checkConflict: async () => { + const query = gql` + query ($scope: EmailCampaignContentScopeInput!) { + brevoConfig(scope: $scope) { + updatedAt + } + } + `; + const { data } = await client.query({ + query, + variables: { scope }, + fetchPolicy: "no-cache", + }); + + return resolveHasSaveConflict(data?.brevoConfig?.updatedAt, data.updatedAt); + }, + formApiRef, + loadLatestVersion: async () => { + await refetch(); + }, + }); + + const handleSubmit = async (state: FormValues, form: FormApi, event: FinalFormSubmitEvent) => { + if (await saveConflict.checkForConflicts()) { + throw new Error("Conflicts detected"); + } + + const sender = sendersData?.senders?.find((s) => s.email === state.sender.value); + + if (!sender) { + throw new Error("No sender selected"); + } + + const output = { + senderName: sender?.name, + senderMail: sender?.email, + }; + + if (mode === "edit") { + if (!data?.brevoConfig?.id) { + throw new Error("Missing id in edit mode"); + } + await client.mutate({ + mutation: updateBrevoConfigMutation, + variables: { id: data?.brevoConfig?.id, input: output, lastUpdatedAt: data?.brevoConfig?.updatedAt }, + }); + } else { + const { data: mutationResponse } = await client.mutate({ + mutation: createBrevoConfigMutation, + variables: { scope, input: output }, + }); + if (!event.navigatingBack) { + const id = mutationResponse?.createBrevoConfig.id; + if (id) { + setTimeout(() => { + stackSwitchApi.activatePage("edit", id); + }); + } + } + } + }; + + if (error || senderError) throw error ?? senderError; + + if (loading || senderLoading) { + return ; + } + + return ( + apiRef={formApiRef} onSubmit={handleSubmit} mode={mode} initialValues={initialValues}> + {({ values }) => { + return ( + + {saveConflict.dialogs} + + + + + + + + + + + option.label} + isOptionEqualToValue={(option: Option, value: Option) => option.value === value.value} + options={senderOptions} + name="sender" + label={} + fullWidth + required + /> + + + ); + }} + + ); +} diff --git a/packages/admin/src/brevoConfiguration/BrevoConfigPage.tsx b/packages/admin/src/brevoConfiguration/BrevoConfigPage.tsx new file mode 100644 index 00000000..f8c8163b --- /dev/null +++ b/packages/admin/src/brevoConfiguration/BrevoConfigPage.tsx @@ -0,0 +1,22 @@ +import { useContentScope } from "@comet/cms-admin"; +import * as React from "react"; + +import { BrevoConfigForm } from "./BrevoConfigForm"; + +interface CreateBrevoConfigPageOptions { + scopeParts: string[]; +} + +export function createBrevoConfigPage({ scopeParts }: CreateBrevoConfigPageOptions) { + function BrevoConfigPage(): JSX.Element { + const { scope: completeScope } = useContentScope(); + + const scope = scopeParts.reduce((acc, scopePart) => { + acc[scopePart] = completeScope[scopePart]; + return acc; + }, {} as { [key: string]: unknown }); + + return ; + } + return BrevoConfigPage; +} diff --git a/packages/admin/src/emailCampaigns/form/EmailCampaignForm.tsx b/packages/admin/src/emailCampaigns/form/EmailCampaignForm.tsx index 7ea120b9..a65397b0 100644 --- a/packages/admin/src/emailCampaigns/form/EmailCampaignForm.tsx +++ b/packages/admin/src/emailCampaigns/form/EmailCampaignForm.tsx @@ -52,6 +52,7 @@ import { GQLUpdateEmailCampaignMutationVariables, } from "./EmailCampaignForm.gql.generated"; import { SendManagerFields } from "./SendManagerFields"; +import { SendManagerWrapper } from "./SendManagerWrapper"; import { TestEmailCampaignForm } from "./TestEmailCampaignForm"; interface FormProps { @@ -286,13 +287,15 @@ export function EmailCampaignForm({ id, EmailCampaignContentBlock, scope, previe scheduledAt: state.scheduledAt, }} > - - + + + + ), }, diff --git a/packages/admin/src/emailCampaigns/form/SendManagerWrapper.gql.ts b/packages/admin/src/emailCampaigns/form/SendManagerWrapper.gql.ts new file mode 100644 index 00000000..720e9444 --- /dev/null +++ b/packages/admin/src/emailCampaigns/form/SendManagerWrapper.gql.ts @@ -0,0 +1,9 @@ +import { gql } from "@apollo/client"; + +export const brevoConfigQuery = gql` + query BrevoConfig($scope: EmailCampaignContentScopeInput!) { + brevoConfig(scope: $scope) { + id + } + } +`; diff --git a/packages/admin/src/emailCampaigns/form/SendManagerWrapper.tsx b/packages/admin/src/emailCampaigns/form/SendManagerWrapper.tsx new file mode 100644 index 00000000..511e5519 --- /dev/null +++ b/packages/admin/src/emailCampaigns/form/SendManagerWrapper.tsx @@ -0,0 +1,43 @@ +import { useQuery } from "@apollo/client"; +import { Loading } from "@comet/admin"; +import { ContentScopeInterface } from "@comet/cms-admin"; +import { Typography } from "@mui/material"; +import * as React from "react"; +import { FormattedMessage } from "react-intl"; + +import { brevoConfigQuery } from "./SendManagerWrapper.gql"; +import { GQLBrevoConfigQuery, GQLBrevoConfigQueryVariables } from "./SendManagerWrapper.gql.generated"; + +interface SendManagerWrapperProps { + scope: ContentScopeInterface; +} + +export const SendManagerWrapper = ({ scope, children }: React.PropsWithChildren) => { + const { + data: brevoConfig, + loading, + error, + } = useQuery(brevoConfigQuery, { + variables: { scope }, + fetchPolicy: "network-only", + }); + + if (loading) { + return ; + } + + if (error) throw error; + + if (brevoConfig?.brevoConfig?.id == undefined) { + return ( + + + + ); + } + + return <> {children} ; +}; diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts index 649ac487..33ed2f25 100644 --- a/packages/admin/src/index.ts +++ b/packages/admin/src/index.ts @@ -1,3 +1,4 @@ +export { createBrevoConfigPage } from "./brevoConfiguration/BrevoConfigPage"; export { createBrevoContactsPage } from "./brevoContacts/BrevoContactsPage"; export { createEmailCampaignsPage } from "./emailCampaigns/EmailCampaignsPage"; export { EditTargetGroupFinalFormValues } from "./targetGroups/TargetGroupForm"; diff --git a/packages/api/generate-schema.ts b/packages/api/generate-schema.ts index be784d76..52b269b9 100644 --- a/packages/api/generate-schema.ts +++ b/packages/api/generate-schema.ts @@ -6,6 +6,8 @@ import { Field, GraphQLSchemaBuilderModule, GraphQLSchemaFactory, InputType, Obj import { writeFile } from "fs/promises"; import { printSchema } from "graphql"; +import { createBrevoConfigResolver } from "./src/brevo-config/brevo-config.resolver"; +import { BrevoConfigEntityFactory } from "./src/brevo-config/entities/brevo-config-entity.factory"; import { createBrevoContactResolver } from "./src/brevo-contact/brevo-contact.resolver"; import { BrevoContactFactory } from "./src/brevo-contact/dto/brevo-contact.factory"; import { SubscribeInputFactory } from "./src/brevo-contact/dto/subscribe-input.factory"; @@ -76,7 +78,13 @@ async function generateSchema(): Promise { Scope: EmailCampaignScope, }); - const schema = await gqlSchemaFactory.create([BrevoContactResolver, TargetGroupResolver, EmailCampaignResolver]); + const BrevoConfig = BrevoConfigEntityFactory.create({ Scope: EmailCampaignScope }); + const BrevoConfigResolver = createBrevoConfigResolver({ + BrevoConfig, + Scope: EmailCampaignScope, + }); + + const schema = await gqlSchemaFactory.create([BrevoContactResolver, TargetGroupResolver, EmailCampaignResolver, BrevoConfigResolver]); await writeFile("schema.gql", printSchema(schema)); diff --git a/packages/api/schema.gql b/packages/api/schema.gql index 060eab10..b0913494 100644 --- a/packages/api/schema.gql +++ b/packages/api/schema.gql @@ -44,6 +44,20 @@ type User { language: String! } +type BrevoApiSender { + id: ID! + name: String! + email: String! + active: Boolean! + ips: [BrevoIp!] +} + +type BrevoIp { + ip: String! + domain: String! + weight: Int! +} + type BrevoApiCampaignStatistics { """Number of unique clicks for the campaign""" uniqueClicks: Int! @@ -159,6 +173,15 @@ type PaginatedEmailCampaigns { totalCount: Int! } +type BrevoConfig implements DocumentInterface { + id: ID! + updatedAt: DateTime! + senderMail: String! + senderName: String! + createdAt: DateTime! + scope: EmailCampaignContentScope! +} + input EmailCampaignContentScopeInput { thisScopeHasNoFields____: String } @@ -171,6 +194,8 @@ type Query { emailCampaign(id: ID!): EmailCampaign! emailCampaigns(scope: EmailCampaignContentScopeInput!, search: String, filter: EmailCampaignFilter, sort: [EmailCampaignSort!], offset: Int! = 0, limit: Int! = 25): PaginatedEmailCampaigns! emailCampaignStatistics(id: ID!): BrevoApiCampaignStatistics + senders: [BrevoApiSender!] + brevoConfig(scope: EmailCampaignContentScopeInput!): BrevoConfig } input TargetGroupFilter { @@ -249,6 +274,8 @@ type Mutation { deleteEmailCampaign(id: ID!): Boolean! sendEmailCampaignNow(id: ID!): Boolean! sendEmailCampaignToTestEmails(id: ID!, data: SendTestEmailCampaignArgs!): Boolean! + createBrevoConfig(scope: EmailCampaignContentScopeInput!, input: BrevoConfigInput!): BrevoConfig! + updateBrevoConfig(id: ID!, input: BrevoConfigUpdateInput!, lastUpdatedAt: DateTime): BrevoConfig! } input BrevoContactUpdateInput { @@ -302,3 +329,13 @@ input EmailCampaignUpdateInput { input SendTestEmailCampaignArgs { emails: [String!]! } + +input BrevoConfigInput { + senderMail: String! + senderName: String! +} + +input BrevoConfigUpdateInput { + senderMail: String! + senderName: String! +} diff --git a/packages/api/src/brevo-api/brevo-api-campaigns.service.ts b/packages/api/src/brevo-api/brevo-api-campaigns.service.ts index b1bd251a..5158b452 100644 --- a/packages/api/src/brevo-api/brevo-api-campaigns.service.ts +++ b/packages/api/src/brevo-api/brevo-api-campaigns.service.ts @@ -27,13 +27,23 @@ export class BrevoApiCampaignsService { return SendingState.DRAFT; } - public async createBrevoCampaign(campaign: EmailCampaignInterface, htmlContent: string, scheduledAt?: Date): Promise { + public async createBrevoCampaign({ + campaign, + htmlContent, + sender, + scheduledAt, + }: { + campaign: EmailCampaignInterface; + htmlContent: string; + sender: { name: string; mail: string }; + scheduledAt?: Date; + }): Promise { const targetGroup = await campaign.targetGroup?.load(); const emailCampaign = { name: campaign.title, subject: campaign.subject, - sender: { name: this.config.brevo.sender.name, email: this.config.brevo.sender.email }, + sender: { name: sender.name, email: sender.mail }, recipients: { listIds: targetGroup ? [targetGroup?.brevoId] : [] }, htmlContent, scheduledAt: scheduledAt?.toISOString(), @@ -43,13 +53,25 @@ export class BrevoApiCampaignsService { return data.body.id; } - public async updateBrevoCampaign(id: number, campaign: EmailCampaignInterface, htmlContent: string, scheduledAt?: Date): Promise { + public async updateBrevoCampaign({ + id, + campaign, + htmlContent, + scheduledAt, + sender, + }: { + id: number; + campaign: EmailCampaignInterface; + htmlContent: string; + sender: { name: string; mail: string }; + scheduledAt?: Date; + }): Promise { const targetGroup = await campaign.targetGroup?.load(); const emailCampaign = { name: campaign.title, subject: campaign.subject, - sender: { name: this.config.brevo.sender.name, email: this.config.brevo.sender.email }, + sender: { name: sender.name, mail: sender.mail }, recipients: { listIds: targetGroup ? [targetGroup?.brevoId] : [] }, htmlContent, scheduledAt: scheduledAt?.toISOString(), diff --git a/packages/api/src/brevo-api/brevo-api-sender.service.ts b/packages/api/src/brevo-api/brevo-api-sender.service.ts new file mode 100644 index 00000000..14ce1746 --- /dev/null +++ b/packages/api/src/brevo-api/brevo-api-sender.service.ts @@ -0,0 +1,26 @@ +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 { BrevoApiSender } from "./dto/brevo-api-sender"; + +@Injectable() +export class BrevoApiSenderService { + private readonly senderApi: SibApiV3Sdk.SendersApi; + + constructor(@Inject(BREVO_MODULE_CONFIG) private readonly config: BrevoModuleConfig) { + this.senderApi = new SibApiV3Sdk.SendersApi(); + this.senderApi.setApiKey(SibApiV3Sdk.SendersApiApiKeys.apiKey, config.brevo.apiKey); + } + + public async getSenders(): Promise | undefined> { + const { response, body } = await this.senderApi.getSenders(); + + if (response.statusCode !== 200) { + throw new Error("Failed to get senders"); + } + + return body.senders as BrevoApiSender[]; + } +} diff --git a/packages/api/src/brevo-api/brevo-api.module.ts b/packages/api/src/brevo-api/brevo-api.module.ts index 11159330..cd8e9cfc 100644 --- a/packages/api/src/brevo-api/brevo-api.module.ts +++ b/packages/api/src/brevo-api/brevo-api.module.ts @@ -3,10 +3,11 @@ 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"; +import { BrevoApiSenderService } from "./brevo-api-sender.service"; @Module({ imports: [ConfigModule], - providers: [BrevoApiContactsService, BrevoApiCampaignsService], - exports: [BrevoApiContactsService, BrevoApiCampaignsService], + providers: [BrevoApiContactsService, BrevoApiCampaignsService, BrevoApiSenderService], + exports: [BrevoApiContactsService, BrevoApiCampaignsService, BrevoApiSenderService], }) export class BrevoApiModule {} diff --git a/packages/api/src/brevo-api/dto/brevo-api-sender.ts b/packages/api/src/brevo-api/dto/brevo-api-sender.ts new file mode 100644 index 00000000..a8048464 --- /dev/null +++ b/packages/api/src/brevo-api/dto/brevo-api-sender.ts @@ -0,0 +1,31 @@ +import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class BrevoApiSender { + @Field(() => ID) + id: number; + + @Field(() => String) + name: string; + + @Field(() => String) + email: string; + + @Field(() => Boolean) + active: boolean; + + @Field(() => Array(BrevoIp), { nullable: true }) + ips?: Array; +} + +@ObjectType() +class BrevoIp { + @Field(() => String) + ip: string; + + @Field(() => String) + domain: string; + + @Field(() => Int) + weight: number; +} diff --git a/packages/api/src/brevo-config/brevo-config.module.ts b/packages/api/src/brevo-config/brevo-config.module.ts new file mode 100644 index 00000000..aa3363af --- /dev/null +++ b/packages/api/src/brevo-config/brevo-config.module.ts @@ -0,0 +1,25 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { DynamicModule, Module, Type } from "@nestjs/common"; + +import { BrevoApiModule } from "../brevo-api/brevo-api.module"; +import { EmailCampaignScopeInterface } from "../types"; +import { createBrevoConfigResolver } from "./brevo-config.resolver"; +import { BrevoConfigInterface } from "./entities/brevo-config-entity.factory"; + +interface BrevoConfigModuleConfig { + Scope: Type; + BrevoConfig: Type; +} + +@Module({}) +export class BrevoConfigModule { + static register({ Scope, BrevoConfig }: BrevoConfigModuleConfig): DynamicModule { + const BrevoConfigResolver = createBrevoConfigResolver({ BrevoConfig, Scope }); + + return { + module: BrevoConfigModule, + imports: [MikroOrmModule.forFeature([BrevoConfig]), BrevoApiModule], + providers: [BrevoConfigResolver], + }; + } +} diff --git a/packages/api/src/brevo-config/brevo-config.resolver.ts b/packages/api/src/brevo-config/brevo-config.resolver.ts new file mode 100644 index 00000000..c587cf24 --- /dev/null +++ b/packages/api/src/brevo-config/brevo-config.resolver.ts @@ -0,0 +1,103 @@ +import { AffectedEntity, RequiredPermission, validateNotModified } from "@comet/cms-api"; +import { EntityManager, EntityRepository, wrap } from "@mikro-orm/core"; +import { InjectRepository } from "@mikro-orm/nestjs"; +import { Type } from "@nestjs/common"; +import { Args, ID, Mutation, Query, Resolver } from "@nestjs/graphql"; + +import { BrevoApiSenderService } from "../brevo-api/brevo-api-sender.service"; +import { BrevoApiSender } from "../brevo-api/dto/brevo-api-sender"; +import { EmailCampaignScopeInterface } from "../types"; +import { DynamicDtoValidationPipe } from "../validation/dynamic-dto-validation.pipe"; +import { BrevoConfigInput, BrevoConfigUpdateInput } from "./dto/brevo-config.input"; +import { BrevoConfigInterface } from "./entities/brevo-config-entity.factory"; + +export function createBrevoConfigResolver({ + Scope, + BrevoConfig, +}: { + Scope: Type; + BrevoConfig: Type; +}): Type { + @Resolver(() => BrevoConfig) + @RequiredPermission(["brevo-newsletter-config"]) + class BrevoConfigResolver { + constructor( + private readonly entityManager: EntityManager, + private readonly brevoSenderApiService: BrevoApiSenderService, + @InjectRepository(BrevoConfig) private readonly repository: EntityRepository, + ) {} + + private async isValidSender({ email, name }: { email: string; name: string }): Promise { + const senders = await this.brevoSenderApiService.getSenders(); + + if (senders && senders.some((sender) => sender.email === email && sender.name === name)) { + return true; + } + + return false; + } + + @RequiredPermission(["brevo-newsletter-config"], { skipScopeCheck: true }) + @Query(() => [BrevoApiSender], { nullable: true }) + async senders(): Promise | undefined> { + const senders = await this.brevoSenderApiService.getSenders(); + return senders; + } + + @Query(() => BrevoConfig, { nullable: true }) + async brevoConfig( + @Args("scope", { type: () => Scope }, new DynamicDtoValidationPipe(Scope)) + scope: typeof Scope, + ): Promise { + const brevoConfig = await this.repository.findOne({ scope }); + return brevoConfig; + } + + @Mutation(() => BrevoConfig) + async createBrevoConfig( + @Args("scope", { type: () => Scope }, new DynamicDtoValidationPipe(Scope)) + scope: typeof Scope, + @Args("input", { type: () => BrevoConfigInput }) input: BrevoConfigInput, + ): Promise { + if (!(await this.isValidSender({ email: input.senderMail, name: input.senderName }))) { + throw new Error("Sender not found"); + } + + const brevoConfig = this.repository.create({ + ...input, + scope, + }); + + await this.entityManager.flush(); + + return brevoConfig; + } + + @Mutation(() => BrevoConfig) + @AffectedEntity(BrevoConfig) + async updateBrevoConfig( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => BrevoConfigUpdateInput }) input: BrevoConfigUpdateInput, + @Args("lastUpdatedAt", { type: () => Date, nullable: true }) lastUpdatedAt?: Date, + ): Promise { + const brevoConfig = await this.repository.findOneOrFail(id); + if (lastUpdatedAt) { + validateNotModified(brevoConfig, lastUpdatedAt); + } + + if (!(await this.isValidSender({ email: input.senderMail, name: input.senderName }))) { + throw new Error("Sender not found"); + } + + wrap(brevoConfig).assign({ + ...input, + }); + + await this.entityManager.flush(); + + return brevoConfig; + } + } + + return BrevoConfigResolver; +} diff --git a/packages/api/src/brevo-config/dto/brevo-config.input.ts b/packages/api/src/brevo-config/dto/brevo-config.input.ts new file mode 100644 index 00000000..ebb63908 --- /dev/null +++ b/packages/api/src/brevo-config/dto/brevo-config.input.ts @@ -0,0 +1,19 @@ +import { Field, InputType } from "@nestjs/graphql"; +import { IsEmail, IsNotEmpty, IsString } from "class-validator"; + +@InputType() +export class BrevoConfigInput { + @IsNotEmpty() + @IsString() + @Field() + @IsEmail() + senderMail: string; + + @IsNotEmpty() + @IsString() + @Field() + senderName: string; +} + +@InputType() +export class BrevoConfigUpdateInput extends BrevoConfigInput {} diff --git a/packages/api/src/brevo-config/entities/brevo-config-entity.factory.ts b/packages/api/src/brevo-config/entities/brevo-config-entity.factory.ts new file mode 100644 index 00000000..57bd5fdd --- /dev/null +++ b/packages/api/src/brevo-config/entities/brevo-config-entity.factory.ts @@ -0,0 +1,60 @@ +import { DocumentInterface } from "@comet/cms-api"; +import { Embedded, Entity, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core"; +import { Type } from "@nestjs/common"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { v4 } from "uuid"; + +import { EmailCampaignScopeInterface } from "../../types"; + +export interface BrevoConfigInterface { + [OptionalProps]?: "createdAt" | "updatedAt"; + id: string; + senderName: string; + senderMail: string; + createdAt: Date; + updatedAt: Date; + scope: EmailCampaignScopeInterface; +} + +export class BrevoConfigEntityFactory { + static create({ Scope }: { Scope: EmailCampaignScopeInterface }): Type { + @Entity() + @ObjectType({ + implements: () => [DocumentInterface], + }) + class BrevoConfig implements BrevoConfigInterface, DocumentInterface { + [OptionalProps]?: "createdAt" | "updatedAt"; + + @PrimaryKey({ columnType: "uuid" }) + @Field(() => ID) + id: string = v4(); + + @Property({ columnType: "text" }) + @Field() + senderMail: string; + + @Property({ columnType: "text" }) + @Field() + senderName: string; + + @Property({ + columnType: "timestamp with time zone", + }) + @Field() + createdAt: Date = new Date(); + + @Property({ + columnType: "timestamp with time zone", + onUpdate: () => new Date(), + }) + @Field() + updatedAt: Date = new Date(); + + @Embedded(() => Scope) + @Field(() => Scope) + scope: typeof Scope; + } + + return BrevoConfig; + } +} diff --git a/packages/api/src/brevo-module.ts b/packages/api/src/brevo-module.ts index 36cab741..c96ed65a 100644 --- a/packages/api/src/brevo-module.ts +++ b/packages/api/src/brevo-module.ts @@ -1,6 +1,8 @@ import { DynamicModule, Global, Module } from "@nestjs/common"; import { BrevoApiModule } from "./brevo-api/brevo-api.module"; +import { BrevoConfigModule } from "./brevo-config/brevo-config.module"; +import { BrevoConfigEntityFactory } from "./brevo-config/entities/brevo-config-entity.factory"; import { BrevoContactModule } from "./brevo-contact/brevo-contact.module"; import { BrevoModuleConfig } from "./config/brevo-module.config"; import { ConfigModule } from "./config/config.module"; @@ -17,6 +19,10 @@ export class BrevoModule { BrevoFilterAttributes: config.brevo.BrevoContactFilterAttributes, }); + const BrevoConfig = BrevoConfigEntityFactory.create({ + Scope: config.emailCampaigns.Scope, + }); + return { module: BrevoModule, imports: [ @@ -30,12 +36,14 @@ export class BrevoModule { EmailCampaignContentBlock: config.emailCampaigns.EmailCampaignContentBlock, Scope: config.emailCampaigns.Scope, TargetGroup, + BrevoConfig, }), TargetGroupModule.register({ Scope: config.emailCampaigns.Scope, BrevoFilterAttributes: config.brevo.BrevoContactFilterAttributes, TargetGroup: TargetGroup, }), + BrevoConfigModule.register({ BrevoConfig, Scope: config.emailCampaigns.Scope }), ConfigModule.forRoot(config), ], exports: [TargetGroupModule], diff --git a/packages/api/src/config/brevo-module.config.ts b/packages/api/src/config/brevo-module.config.ts index fc5829ec..7202b16c 100644 --- a/packages/api/src/config/brevo-module.config.ts +++ b/packages/api/src/config/brevo-module.config.ts @@ -11,10 +11,6 @@ export interface BrevoModuleConfig { BrevoContactFilterAttributes?: Type; doubleOptInTemplateId: number; allowedRedirectUrl: string; - sender: { - name: string; - email: string; - }; }; ecgRtrList: { apiKey: string; diff --git a/packages/api/src/email-campaign/email-campaign.module.ts b/packages/api/src/email-campaign/email-campaign.module.ts index 4d1ed54b..b130633d 100644 --- a/packages/api/src/email-campaign/email-campaign.module.ts +++ b/packages/api/src/email-campaign/email-campaign.module.ts @@ -2,6 +2,7 @@ import { Block } from "@comet/blocks-api"; import { MikroOrmModule } from "@mikro-orm/nestjs"; import { HttpModule } from "@nestjs/axios"; import { DynamicModule, Module, Type } from "@nestjs/common"; +import { BrevoConfigInterface } from "src/brevo-config/entities/brevo-config-entity.factory"; import { TargetGroupInterface } from "src/target-group/entity/target-group-entity.factory"; import { BrevoApiModule } from "../brevo-api/brevo-api.module"; @@ -17,11 +18,12 @@ interface EmailCampaignModuleConfig { Scope: Type; EmailCampaignContentBlock: Block; TargetGroup: Type; + BrevoConfig: Type; } @Module({}) export class EmailCampaignModule { - static register({ Scope, EmailCampaignContentBlock, TargetGroup }: EmailCampaignModuleConfig): DynamicModule { + static register({ Scope, EmailCampaignContentBlock, TargetGroup, BrevoConfig }: EmailCampaignModuleConfig): DynamicModule { const EmailCampaign = EmailCampaignEntityFactory.create({ Scope, EmailCampaignContentBlock, @@ -44,7 +46,7 @@ export class EmailCampaignModule { HttpModule.register({ timeout: 5000, }), - MikroOrmModule.forFeature([EmailCampaign, TargetGroup]), + MikroOrmModule.forFeature([EmailCampaign, TargetGroup, BrevoConfig]), ], providers: [EmailCampaignsResolver, EmailCampaignsService, EcgRtrListService], }; diff --git a/packages/api/src/email-campaign/email-campaigns.service.ts b/packages/api/src/email-campaign/email-campaigns.service.ts index 7c6f9854..f5c66a78 100644 --- a/packages/api/src/email-campaign/email-campaigns.service.ts +++ b/packages/api/src/email-campaign/email-campaigns.service.ts @@ -7,6 +7,7 @@ import { UpdateCampaignStatus } from "@sendinblue/client"; import { BrevoApiCampaignsService } from "../brevo-api/brevo-api-campaigns.service"; import { BrevoApiContactsService } from "../brevo-api/brevo-api-contact.service"; +import { BrevoConfigInterface } from "../brevo-config/entities/brevo-config-entity.factory"; 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"; @@ -18,6 +19,7 @@ export class EmailCampaignsService { constructor( @Inject(BREVO_MODULE_CONFIG) private readonly config: BrevoModuleConfig, @InjectRepository("EmailCampaign") private readonly repository: EntityRepository, + @InjectRepository("BrevoConfig") private readonly brevoConfigRepository: EntityRepository, private readonly httpService: HttpService, private readonly brevoApiCampaignService: BrevoApiCampaignsService, private readonly brevoApiContactsService: BrevoApiContactsService, @@ -42,6 +44,7 @@ export class EmailCampaignsService { async saveEmailCampaignInBrevo(id: string, scheduledAt?: Date): Promise { const campaign = await this.repository.findOneOrFail(id); + const brevoConfig = await this.brevoConfigRepository.findOneOrFail({ scope: campaign.scope }); const content = await this.blockTransformerService.transformToPlain(campaign.content); @@ -63,13 +66,24 @@ export class EmailCampaignsService { let brevoId = campaign.brevoId; if (!brevoId) { - brevoId = await this.brevoApiCampaignService.createBrevoCampaign(campaign, htmlContent, scheduledAt); + brevoId = await this.brevoApiCampaignService.createBrevoCampaign({ + campaign, + htmlContent, + scheduledAt, + sender: { name: brevoConfig.senderName, mail: brevoConfig.senderMail }, + }); wrap(campaign).assign({ brevoId }); await this.entityManager.flush(); } else { - await this.brevoApiCampaignService.updateBrevoCampaign(brevoId, campaign, htmlContent, scheduledAt); + await this.brevoApiCampaignService.updateBrevoCampaign({ + id: brevoId, + campaign, + htmlContent, + scheduledAt, + sender: { name: brevoConfig.senderName, mail: brevoConfig.senderMail }, + }); } return campaign;