From 97bbb30f0228a356d47c095ac86d94ecfa615ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=BCndig?= Date: Sun, 31 Mar 2024 21:16:59 +0200 Subject: [PATCH] feature(functions): first payout email function (initial version) (#789) --- .github/workflows/deployment.yml | 15 +-- functions/.env.sample | 2 - functions/package.json | 1 + functions/src/config.ts | 8 -- .../src/cron/first-payout-email/index.ts | 73 ++++++++++++ .../admin/donation-certificates/index.ts | 28 +---- package-lock.json | 108 +++++++++++++++++- shared/src/sendgrid/SendgridMailClient.ts | 24 ++++ ui/package.json | 10 +- 9 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 functions/src/cron/first-payout-email/index.ts create mode 100644 shared/src/sendgrid/SendgridMailClient.ts diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index efea04300..cd1895b16 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -80,23 +80,18 @@ jobs: - name: Add secrets to functions/.env file working-directory: functions run: | - echo POSTFINANCE_EMAIL_PASSWORD=${{ secrets.POSTFINANCE_EMAIL_PASSWORD }} > .env - echo POSTFINANCE_PAYMENTS_FILES_BUCKET=${{ (inputs.project == 'social-income-prod' && vars.POSTFINANCE_PAYMENTS_FILES_BUCKET) || vars.POSTFINANCE_PAYMENTS_FILES_BUCKET_STAGING }} >> .env + echo EXCHANGE_RATES_API=${{ secrets.EXCHANGE_RATES_API }} >> .env echo POSTFINANCE_FTP_HOST=${{ vars.POSTFINANCE_FTP_HOST }} >> .env echo POSTFINANCE_FTP_PORT=${{ vars.POSTFINANCE_FTP_PORT }} >> .env - echo POSTFINANCE_FTP_USER=${{ vars.POSTFINANCE_FTP_USER }} >> .env echo POSTFINANCE_FTP_RSA_PRIVATE_KEY_BASE64=${{ secrets.POSTFINANCE_FTP_RSA_PRIVATE_KEY_BASE64 }} >> .env + echo POSTFINANCE_FTP_USER=${{ vars.POSTFINANCE_FTP_USER }} >> .env + echo POSTFINANCE_PAYMENTS_FILES_BUCKET=${{ (inputs.project == 'social-income-prod' && vars.POSTFINANCE_PAYMENTS_FILES_BUCKET) || vars.POSTFINANCE_PAYMENTS_FILES_BUCKET_STAGING }} >> .env + echo SENDGRID_API_KEY=${{ secrets.SENDGRID_API_KEY }} >> .env echo STRIPE_API_READ_KEY=${{ (inputs.project == 'social-income-prod' && secrets.STRIPE_API_READ_KEY) || secrets.STRIPE_API_READ_KEY_STAGING }} >> .env echo STRIPE_WEBHOOK_SECRET=${{ (inputs.project == 'social-income-prod' && secrets.STRIPE_WEBHOOK_SECRET) || secrets.STRIPE_WEBHOOK_SECRET_STAGING }} >> .env - echo NOTIFICATION_EMAIL_USER=${{ secrets.NOTIFICATION_EMAIL_USER }} >> .env - echo NOTIFICATION_EMAIL_PASSWORD=${{ secrets.NOTIFICATION_EMAIL_PASSWORD }} >> .env - echo NOTIFICATION_EMAIL_USER_KERRIN=${{ secrets.NOTIFICATION_EMAIL_USER_KERRIN }} >> .env - echo NOTIFICATION_EMAIL_PASSWORD_KERRIN=${{ secrets.NOTIFICATION_EMAIL_PASSWORD_KERRIN }} >> .env + echo TWILIO_SENDER_PHONE=${{ secrets.TWILIO_SENDER_PHONE }} >> .env echo TWILIO_SID=${{ secrets.TWILIO_SID }} >> .env echo TWILIO_TOKEN=${{ secrets.TWILIO_TOKEN }} >> .env - echo TWILIO_SENDER_PHONE=${{ secrets.TWILIO_SENDER_PHONE }} >> .env - echo POSTFINANCE_EMAIL_USER=${{ secrets.POSTFINANCE_EMAIL_USER }} >> .env - echo EXCHANGE_RATES_API=${{ secrets.EXCHANGE_RATES_API }} >> .env - name: Build functions run: npm run functions:build diff --git a/functions/.env.sample b/functions/.env.sample index a71ef6cf9..da6ef5628 100644 --- a/functions/.env.sample +++ b/functions/.env.sample @@ -3,8 +3,6 @@ GCLOUD_PROJECT="social-income-staging" FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 -POSTFINANCE_EMAIL_USER=test@socialincome.org -POSTFINANCE_EMAIL_PASSWORD=password POSTFINANCE_PAYMENTS_FILES_BUCKET=postfinance-payments-files POSTFINANCE_FTP_HOST=ftp.postfinance.example POSTFINANCE_FTP_PORT=21 diff --git a/functions/package.json b/functions/package.json index 3ee674e6b..b847e40c1 100644 --- a/functions/package.json +++ b/functions/package.json @@ -31,6 +31,7 @@ "typescript": "^5.4.3" }, "dependencies": { + "@sendgrid/mail": "^8.1.1", "@types/ssh2-sftp-client": "^9.0.3", "@xmldom/xmldom": "^0.8.10", "axios": "^1.6.8", diff --git a/functions/src/config.ts b/functions/src/config.ts index e4bdda429..a3b5aa93a 100644 --- a/functions/src/config.ts +++ b/functions/src/config.ts @@ -8,20 +8,12 @@ export const ASSET_DIR = path.join(__dirname, '..', '..', 'shared', 'assets'); export const STRIPE_API_READ_KEY = process.env.STRIPE_API_READ_KEY!; export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; -export const POSTFINANCE_EMAIL_USER = process.env.POSTFINANCE_EMAIL_USER!; -export const POSTFINANCE_EMAIL_PASSWORD = process.env.POSTFINANCE_EMAIL_PASSWORD!; - export const POSTFINANCE_PAYMENTS_FILES_BUCKET = process.env.POSTFINANCE_PAYMENTS_FILES_BUCKET!; export const POSTFINANCE_FTP_RSA_PRIVATE_KEY_BASE64 = process.env.POSTFINANCE_FTP_RSA_PRIVATE_KEY_BASE64!; export const POSTFINANCE_FTP_HOST = process.env.POSTFINANCE_FTP_HOST!; export const POSTFINANCE_FTP_PORT = process.env.POSTFINANCE_FTP_PORT!; export const POSTFINANCE_FTP_USER = process.env.POSTFINANCE_FTP_USER!; -export const NOTIFICATION_EMAIL_USER = process.env.NOTIFICATION_EMAIL_USER!; -export const NOTIFICATION_EMAIL_PASSWORD = process.env.NOTIFICATION_EMAIL_PASSWORD!; -export const NOTIFICATION_EMAIL_USER_KERRIN = process.env.NOTIFICATION_EMAIL_USER_KERRIN!; -export const NOTIFICATION_EMAIL_PASSWORD_KERRIN = process.env.NOTIFICATION_EMAIL_PASSWORD_KERRIN!; - export const TWILIO_SID = process.env.TWILIO_SID!; export const TWILIO_TOKEN = process.env.TWILIO_TOKEN!; export const TWILIO_SENDER_PHONE = process.env.TWILIO_SENDER_PHONE!; diff --git a/functions/src/cron/first-payout-email/index.ts b/functions/src/cron/first-payout-email/index.ts new file mode 100644 index 000000000..768aadbdd --- /dev/null +++ b/functions/src/cron/first-payout-email/index.ts @@ -0,0 +1,73 @@ +import { FirestoreAdmin } from '@socialincome/shared/src/firebase/admin/FirestoreAdmin'; +import { toFirebaseAdminTimestamp } from '@socialincome/shared/src/firebase/admin/utils'; +import { Contribution, CONTRIBUTION_FIRESTORE_PATH } from '@socialincome/shared/src/types/contribution'; +import { User, USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; +import { onSchedule } from 'firebase-functions/v2/scheduler'; +import { DateTime } from 'luxon'; +import { FirstPayoutEmailTemplateData, SendgridMailClient } from '../../../../shared/src/sendgrid/SendgridMailClient'; + +export const getFirstPayoutEmailReceivers = async ( + firestoreAdmin: FirestoreAdmin, + from: DateTime, + to: DateTime, +): Promise< + { + email: string; + templateData: FirstPayoutEmailTemplateData; + }[] +> => { + const users = await firestoreAdmin.collection(USER_FIRESTORE_PATH).get(); + return ( + await Promise.all( + users.docs + .filter((userDoc) => !userDoc.get('test_user')) + .map(async (userDoc) => { + const user = userDoc.data(); + const firstContribution = await firestoreAdmin + .collection(`${USER_FIRESTORE_PATH}/${userDoc.id}/${CONTRIBUTION_FIRESTORE_PATH}`) + .orderBy('created', 'asc') + .limit(1) + .get(); + + if (firstContribution.empty) return []; + const contribution = firstContribution.docs[0].data() as Contribution; + if (!contribution) return []; + if ( + contribution.created >= toFirebaseAdminTimestamp(from) && + contribution.created < toFirebaseAdminTimestamp(to) + ) { + return [ + { + email: user.email, + templateData: { + email: user.email, + first_name: user.personal?.name, + donation_amount: contribution.amount, + currency: contribution.currency, + }, + }, + ]; + } + return []; + }), + ) + ).flat(); +}; + +// Run on the 16th of every month at 00:00 +export default onSchedule('0 0 16 * *', async () => { + const sendgridClient = new SendgridMailClient(process.env.SENDGRID_API_KEY!); + const firestoreAdmin = new FirestoreAdmin(); + + const now = DateTime.now(); + const fromDate = DateTime.fromObject({ year: now.year, month: now.month - 1, day: 16, hour: 0 }, { zone: 'utc' }); + const toDate = DateTime.fromObject({ year: now.year, month: now.month, day: 16, hour: 0 }, { zone: 'utc' }); + const firstPayoutEmailReceivers = await getFirstPayoutEmailReceivers(firestoreAdmin, fromDate, toDate); + + await Promise.all( + firstPayoutEmailReceivers.map(async (entry) => { + const { email, templateData } = entry; + await sendgridClient.sendFirstPayoutEmail(email, templateData); + }), + ); +}); diff --git a/functions/src/webhooks/admin/donation-certificates/index.ts b/functions/src/webhooks/admin/donation-certificates/index.ts index f3e283632..cc0f805c2 100644 --- a/functions/src/webhooks/admin/donation-certificates/index.ts +++ b/functions/src/webhooks/admin/donation-certificates/index.ts @@ -59,33 +59,7 @@ export default onCall>( console.info(`Donation certificate document written for user ${userId}`); successCount += 1; - // if (request.data.sendEmails) { - // await sendEmail({ - // to: user.email, - // subject: translator.t('email-subject'), - // // TODO: Use renderEmailTemplate() instead of renderTemplate() - // content: await renderTemplate({ - // language: user.language || 'de', - // translationNamespace: 'donation-certificate', - // hbsTemplatePath: 'email/donation-certificate.hbs', - // context: { - // title: translator.t('title', { context: { year } }), - // signature: translator.t('title', { context: { year } }), - // firstname: user.personal?.name, - // year: year, - // }, - // }), - // attachments: [ - // { - // filename: translator.t('filename', { context: { year } }), - // path: path, - // }, - // ], - // from: NOTIFICATION_EMAIL_USER_KERRIN, - // user: NOTIFICATION_EMAIL_USER_KERRIN, - // password: NOTIFICATION_EMAIL_PASSWORD_KERRIN, - // }); - // } + // TODO: Send email via Sendgrid }); } catch (e) { usersWithFailures.push(userId); diff --git a/package-lock.json b/package-lock.json index 57dfd972c..7bb0c827e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "name": "@socialincome/functions", "version": "1.0.0", "dependencies": { + "@sendgrid/mail": "^8.1.1", "@types/ssh2-sftp-client": "^9.0.3", "@xmldom/xmldom": "^0.8.10", "axios": "^1.6.8", @@ -7348,6 +7349,60 @@ } } }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz", + "integrity": "sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz", + "integrity": "sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-toggle": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", @@ -7911,6 +7966,49 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sendgrid/client": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.1.tgz", + "integrity": "sha512-pg0gYhAdyQil3Aga7/xHVcZFpvDAjAQMNBgMy5njTSkjACoWHmpSi1nWBZM7nIH/ptcRNMpnBbm9B5EvQ8fX2w==", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.6.4" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/helpers/node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.1.tgz", + "integrity": "sha512-tNtmgWLtBA7ZxKtPuEGOaIdEZP1vZSXsj5zg9iuoDBPVj/fNz+7LWzndvTcKumHk5eaDrS0UPXJqBm61m3+H1A==", + "dependencies": { + "@sendgrid/client": "^8.1.1", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -33302,7 +33400,10 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@types/react": ">=18.0.0", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", "clsx": "^2.1.0", @@ -33310,6 +33411,8 @@ "embla-carousel-autoplay": "^8.0.0", "embla-carousel-react": "^8.0.0", "lucide-react": "^0.363.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", "react-hook-form": "^7.51.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" @@ -33328,11 +33431,6 @@ "react-dom": "^18.2.0", "tailwind-merge": "^2.2.2", "tailwindcss": "^3.4.3" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-dom": ">=18.0.0" } }, "ui/node_modules/@radix-ui/react-select": { diff --git a/shared/src/sendgrid/SendgridMailClient.ts b/shared/src/sendgrid/SendgridMailClient.ts new file mode 100644 index 000000000..dac8916fd --- /dev/null +++ b/shared/src/sendgrid/SendgridMailClient.ts @@ -0,0 +1,24 @@ +import { MailService } from '@sendgrid/mail'; +import { Currency } from '../types/currency'; + +export type FirstPayoutEmailTemplateData = { + first_name?: string; + donation_amount: number; + currency: Currency; +}; + +export class SendgridMailClient extends MailService { + constructor(apiKey: string) { + super(); + this.setApiKey(apiKey); + } + + sendFirstPayoutEmail = async (email: string, data: FirstPayoutEmailTemplateData) => { + await this.send({ + to: email, + from: 'hello@socialincome.org', + templateId: 'd-4e616d721b0240509f468c1e5ff22e6d', + dynamicTemplateData: data, + }); + }; +} diff --git a/ui/package.json b/ui/package.json index 73c82142a..6bfb9b6b4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -20,7 +20,6 @@ "tailwindcss": "^3.4.3" }, "dependencies": { - "@types/react": ">=18.0.0", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^3.3.4", @@ -40,7 +39,10 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@types/react": ">=18.0.0", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", "clsx": "^2.1.0", @@ -48,10 +50,10 @@ "embla-carousel-autoplay": "^8.0.0", "embla-carousel-react": "^8.0.0", "lucide-react": "^0.363.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", "react-hook-form": "^7.51.2", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4", - "react": ">=18.0.0", - "react-dom": ">=18.0.0" + "zod": "^3.22.4" } }