Skip to content

Commit

Permalink
feature(website): migrate to sendgrid for newsletter (#819)
Browse files Browse the repository at this point in the history
  • Loading branch information
brennerthomas authored May 28, 2024
1 parent 5d100de commit 77ac331
Show file tree
Hide file tree
Showing 16 changed files with 249 additions and 126 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { firestore } from 'firebase-admin';
import { QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { DateTime } from 'luxon';
import { FirestoreAdmin } from '../../../../../../shared/src/firebase/admin/FirestoreAdmin';
import {
Recipient,
RECIPIENT_FIRESTORE_PATH,
RecipientProgramStatus,
} from '../../../../../../shared/src/types/recipient';
import QueryDocumentSnapshot = firestore.QueryDocumentSnapshot;

export abstract class PaymentTask {
readonly firestoreAdmin: FirestoreAdmin;
Expand Down
40 changes: 37 additions & 3 deletions functions/src/webhooks/stripe/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,57 @@
import { DocumentReference, DocumentSnapshot } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { onRequest } from 'firebase-functions/v2/https';
import Stripe from 'stripe';
import { FirestoreAdmin } from '../../../../shared/src/firebase/admin/FirestoreAdmin';
import {
NEWSLETTER_LIST_ID,
NEWSLETTER_SUPPRESSION_LIST_ID,
SendgridSubscriptionClient,
} from '../../../../shared/src/sendgrid/SendgridSubscriptionClient';
import { StripeEventHandler } from '../../../../shared/src/stripe/StripeEventHandler';
import { Contribution } from '../../../../shared/src/types/contribution';
import { CountryCode } from '../../../../shared/src/types/country';
import { User } from '../../../../shared/src/types/user';
import { STRIPE_API_READ_KEY, STRIPE_WEBHOOK_SECRET } from '../../config';

const addContributorToNewsletter = async (contributionRef: DocumentReference<Contribution>) => {
const newsletterClient = new SendgridSubscriptionClient({
apiKey: process.env.SENDGRID_API_KEY!,
listId: NEWSLETTER_LIST_ID,
suppressionListId: NEWSLETTER_SUPPRESSION_LIST_ID,
});

const user = (await contributionRef.parent.doc().get()) as unknown as DocumentSnapshot<User>;
await newsletterClient.upsertSubscription({
firstname: user.get('personal.name'),
lastname: user.get('personal.lastname'),
email: user.get('email'),
country: user.get('country') as CountryCode,
language: user.get('language') === 'de' ? 'de' : 'en',
status: 'subscribed',
isContributor: true,
});
};

/**
* Stripe webhook to ingest charge events into firestore.
* Adds the relevant information to the contributions subcollection of users.
* Adds the relevant information to the user's contributions subcollection.
*/
export default onRequest(async (request, response) => {
const stripeEventHandler = new StripeEventHandler(STRIPE_API_READ_KEY, new FirestoreAdmin());
const firestoreAdmin = new FirestoreAdmin();
const stripeEventHandler = new StripeEventHandler(STRIPE_API_READ_KEY, firestoreAdmin);

try {
const sig = request.headers['stripe-signature']!;
const event = stripeEventHandler.constructWebhookEvent(request.rawBody, sig, STRIPE_WEBHOOK_SECRET);
const charge = event.data.object as Stripe.Charge;
switch (event.type) {
case 'charge.succeeded':
case 'charge.failed': {
await stripeEventHandler.handleChargeEvent(event.data.object as Stripe.Charge);
const contributionRef = await stripeEventHandler.handleChargeEvent(charge);
if (contributionRef) {
await addContributorToNewsletter(contributionRef);
}
break;
}
default: {
Expand Down
68 changes: 0 additions & 68 deletions shared/src/mailchimp/MailchimpAPI.ts

This file was deleted.

108 changes: 108 additions & 0 deletions shared/src/sendgrid/SendgridSubscriptionClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Client } from '@sendgrid/client';
import { SendgridContactType } from '@socialincome/shared/src/sendgrid/types';
import { CountryCode } from '../types/country';
import { Suppression } from './types';

export const NEWSLETTER_LIST_ID = '2896ee4d-d1e0-4a4a-8565-7e592c377e36';
export const NEWSLETTER_SUPPRESSION_LIST_ID = 45634;

export type NewsletterSubscriptionData = {
firstname?: string;
lastname?: string;
email: string;
language: 'de' | 'en';
country?: CountryCode;
status?: 'subscribed' | 'unsubscribed';
isContributor?: boolean;
};

export type SendgridSubscriptionClientProps = {
apiKey: string;
listId: string;
suppressionListId: number;
};

export class SendgridSubscriptionClient extends Client {
listId: string;
suppressionListId: number; // unsubscribe group id

constructor(sendgridClientProps: SendgridSubscriptionClientProps) {
super();
this.setApiKey(sendgridClientProps.apiKey);
this.listId = sendgridClientProps.listId;
this.suppressionListId = sendgridClientProps.suppressionListId;
}

getContact = async (email: string): Promise<SendgridContactType | null> => {
try {
const [, body] = await this.request({
method: 'POST',
url: '/v3/marketing/contacts/search/emails',
body: { emails: [email] },
});
const contact = body.result[email].contact as SendgridContactType;
const isSuppressed = await this.isSuppressed(email);
return { ...contact, status: isSuppressed ? 'unsubscribed' : 'subscribed' } as SendgridContactType;
} catch (e: any) {
if (e.code === 404) return null;
throw e;
}
};

upsertSubscription = async (data: NewsletterSubscriptionData) => {
const contact = await this.getContact(data.email);
if (!contact) {
await this.addContact(data);
}

if (data.status === 'subscribed') {
await this.removeSuppression(data.email);
} else {
await this.addSuppression(data.email);
}
};

isSuppressed = async (email: string): Promise<boolean> => {
const [_, body] = await this.request({ url: `/v3/asm/suppressions/${email}`, method: 'GET' });
return body.suppressions.some(
(suppression: Suppression) => suppression.id === this.suppressionListId && suppression.suppressed,
);
};

removeSuppression = async (email: string) => {
await this.request({ url: `/v3/asm/groups/${this.suppressionListId}/suppressions/${email}`, method: 'DELETE' });
};

/**
* Add an email to the unsubscribe list.
*/
addSuppression = async (email: string) => {
await this.request({
url: `/v3/asm/groups/${this.suppressionListId}/suppressions`,
method: 'POST',
body: { recipient_emails: [email] },
});
};

/**
* Add a contact to the global contacts list.
*/
addContact = async (data: NewsletterSubscriptionData) => {
await this.request({
url: `/v3/marketing/contacts`,
method: 'PUT',
body: {
list_ids: [this.listId],
contacts: [
{
email: data.email,
first_name: data.firstname,
last_name: data.lastname,
country: data.country,
custom_fields: { language: data.language, is_contributor: data.isContributor },
},
],
},
});
};
}
35 changes: 35 additions & 0 deletions shared/src/sendgrid/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type Suppression = {
name: string;
id: number;
description: string;
is_default: boolean;
suppressed: boolean;
};

export type SendgridContactType = {
address_line_1?: string;
address_line_2?: string;
alternate_emails: string[];
city?: string;
country?: string;
email: string;
first_name?: string;
id?: string;
last_name?: string;
list_ids: string[];
postal_code?: string;
state_province_region?: string;
phone_number?: string;
whatsapp?: string;
line?: string;
facebook?: string;
unique_name?: string;
_metadata: any[];
custom_fields: {
language: string;
is_contributor: string;
};
created_at: string;
updated_at: string;
status?: 'subscribed' | 'unsubscribed';
};
12 changes: 4 additions & 8 deletions shared/src/stripe/StripeEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ export class StripeEventHandler {
fullCharge.status === 'succeeded' ||
(await this.findFirestoreUser(await this.retrieveStripeCustomer(fullCharge.customer as string)))
) {
await this.storeCharge(fullCharge, checkoutMetadata);
return await this.storeCharge(fullCharge, checkoutMetadata);
}
return null;
};

updateUser = async (checkoutSessionId: string, userData: Partial<User>) => {
Expand Down Expand Up @@ -184,13 +185,8 @@ export class StripeEventHandler {
}
const { firstname, lastname } = splitName(customer.name);
return {
personal: {
name: firstname,
lastname: lastname,
},
address: {
country: customer.address?.country as CountryCode,
},
personal: { name: firstname, lastname: lastname },
address: { country: customer.address?.country as CountryCode },
email: customer.email,
stripe_customer_id: customer.id,
payment_reference_id: DateTime.now().toMillis(),
Expand Down
8 changes: 4 additions & 4 deletions shared/src/types/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EntityReference } from 'firecms';
import { capitalizeStringIfUppercase } from '../utils/strings';
import _ from 'lodash';
import { CountryCode } from './country';
import { Currency } from './currency';
import { Employer } from './employers';
Expand Down Expand Up @@ -55,13 +55,13 @@ export const splitName = (name: string) => {
const stripeNames = name.split(' ');
if (stripeNames.length >= 2) {
return {
lastname: capitalizeStringIfUppercase(stripeNames.pop()!),
firstname: capitalizeStringIfUppercase(stripeNames.join(' ')),
lastname: _.upperFirst(stripeNames.pop()!),
firstname: _.upperFirst(stripeNames.join(' ')),
};
} else {
return {
lastname: capitalizeStringIfUppercase(name),
firstname: '',
lastname: _.upperFirst(name),
};
}
};
9 changes: 0 additions & 9 deletions shared/src/utils/strings.ts

This file was deleted.

1 change: 1 addition & 0 deletions website/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ MAILCHIMP_LIST_ID="11223344"
MAILCHIMP_API_KEY="ABC*************"
MAILCHIMP_SERVER="us11"

SENDGRID_API_KEY="SG.**********"
8 changes: 6 additions & 2 deletions website/src/app/[lang]/[region]/(website)/me/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ export const useDonationCertificates = () => {
return { donationCertificates, loading: isLoading || isRefetching, error };
};

// Mailchimp
export const useNewsletterSubscription = () => {
const api = useApi();
const {
Expand All @@ -95,7 +94,12 @@ export const useNewsletterSubscription = () => {
queryKey: ['me', 'newsletter'],
queryFn: async () => {
const response = await api.get('/api/newsletter/subscription');
return (await response.json()).status;
const responseData = await response.json();
if (responseData === null || !responseData.status) {
return 'unsubscribed';
} else {
return responseData.status;
}
},
staleTime: 3600000, // 1 hour
});
Expand Down
Loading

0 comments on commit 77ac331

Please sign in to comment.