diff --git a/.ebstalk.apps.env/scoutgame.env b/.ebstalk.apps.env/scoutgame.env index e75d82e518..802afaf346 100644 --- a/.ebstalk.apps.env/scoutgame.env +++ b/.ebstalk.apps.env/scoutgame.env @@ -31,3 +31,5 @@ SCOUTGAME_S3_BUCKET="scoutgame.public" NFT_ARTWORK_S3_PATH="prd" REACT_APP_SCOUTGAME_INVITE_CODE="1337" REACT_APP_BUILDER_NFT_CONTRACT_ADDRESS="{{pull:secretsmanager:/io.cv.app/prd/buildernft:SecretString:builder_smart_contract_address}}" +MAILGUN_DOMAIN="{{pull:secretsmanager:/io.cv.app/prd/mailgun:SecretString:mailgun_domain}}" +MAILGUN_API_KEY="{{pull:secretsmanager:/io.cv.app/prd/mailgun:SecretString:mailgun_api_key}}" \ No newline at end of file diff --git a/apps/scoutgame/app/api/session/claimable-points/route.ts b/apps/scoutgame/app/api/session/claimable-points/route.ts index 82c45576a3..20d9edba8b 100644 --- a/apps/scoutgame/app/api/session/claimable-points/route.ts +++ b/apps/scoutgame/app/api/session/claimable-points/route.ts @@ -1,6 +1,6 @@ +import { getClaimablePoints } from '@packages/scoutgame/points/getClaimablePoints'; import { NextResponse } from 'next/server'; -import { getClaimablePoints } from 'lib/points/getClaimablePoints'; import { getUserFromSession } from 'lib/session/getUserFromSession'; export async function GET() { diff --git a/apps/scoutgame/components/common/WarpcastLogin/WarpcastLoginButton.tsx b/apps/scoutgame/components/common/WarpcastLogin/WarpcastLoginButton.tsx index 13a1aad1a9..6cef022b13 100644 --- a/apps/scoutgame/components/common/WarpcastLogin/WarpcastLoginButton.tsx +++ b/apps/scoutgame/components/common/WarpcastLogin/WarpcastLoginButton.tsx @@ -38,7 +38,7 @@ export function WarpcastLoginButton({ children, ...props }: ButtonProps) { result } = useAction(loginWithFarcasterAction, { onSuccess: async ({ data }) => { - const nextPage = data?.onboarded ? redirectUrl : '/welcome'; + const nextPage = !data?.onboarded ? '/welcome' : inviteCode ? '/welcome/builder' : redirectUrl || '/home'; if (!data?.success) { return; diff --git a/apps/scoutgame/lib/builders/setupBuilderProfile.ts b/apps/scoutgame/lib/builders/setupBuilderProfile.ts index 10b6e69804..7b0e370c40 100644 --- a/apps/scoutgame/lib/builders/setupBuilderProfile.ts +++ b/apps/scoutgame/lib/builders/setupBuilderProfile.ts @@ -118,7 +118,7 @@ export async function setupBuilderProfile({ }); // mark builder as applied if they haven't been marked as such yet - if (scout.builderStatus === null) { + if (scout.builderStatus === null || inviteCode) { await prisma.scout.update({ where: { id: unsealedUserId diff --git a/apps/scoutgame/tsconfig.json b/apps/scoutgame/tsconfig.json index 7035d2e17e..98f4d813bb 100644 --- a/apps/scoutgame/tsconfig.json +++ b/apps/scoutgame/tsconfig.json @@ -3,7 +3,12 @@ "declarationMap": true, "declaration": true, "rootDir": "../../", - "lib": ["dom", "dom.iterable", "esnext", "webworker"], + "lib": [ + "dom", + "dom.iterable", + "esnext", + "webworker" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -21,35 +26,99 @@ } ], "paths": { - "@connect-shared/*": ["../../@connect-shared/*"], - "apiClient/*": ["./apiClient/*"], - "components/*": ["./components/*"], - "hooks/*": ["./hooks/*"], - "lib/*": ["./lib/*"], - "public/*": ["./public/*"], - "theme/*": ["./theme/*"], - "@root/abis/*": ["../../abis/*"], - "@root/adapters/*": ["../../adapters/*"], - "@root/config/*": ["../../config/*"], - "@root/connectors/*": ["../../connectors/*"], - "@root/lib/*": ["../../lib/*"], - "@root/hooks/*": ["../../hooks/*"], - "@charmverse/core/prisma": ["../../node_modules/@charmverse/core/dist/cjs/prisma"], - "@charmverse/core/prisma-client": ["../../node_modules/@charmverse/core/dist/cjs/prisma-client"], - "@charmverse/core/shared": ["../../node_modules/@charmverse/core/dist/cjs/shared"], - "@charmverse/core/log": ["../../node_modules/@charmverse/core/dist/cjs/lib/log"], - "@charmverse/core/http": ["../../node_modules/@charmverse/core/dist/cjs/http"], - "@charmverse/core/test": ["../../node_modules/@charmverse/core/dist/cjs/test"], - "@charmverse/core/permissions": ["../../node_modules/@charmverse/core/dist/cjs/permissions"], - "@charmverse/core/permissions/flags": ["../../node_modules/@charmverse/core/dist/cjs/permissions-flags"], - "@charmverse/core/pages": ["../../node_modules/@charmverse/core/dist/cjs/pages"], - "@charmverse/core/pages/utilities": ["../../node_modules/@charmverse/core/dist/cjs/pages-utilities"], - "@charmverse/core/proposals": ["../../node_modules/@charmverse/core/dist/cjs/proposals"], - "@charmverse/core/bounties": ["../../node_modules/@charmverse/core/dist/cjs/bounties"], - "@charmverse/core/utilities": ["../../node_modules/@charmverse/core/dist/cjs/utilities"], - "@charmverse/core/errors": ["../../node_modules/@charmverse/core/dist/cjs/errors"] + "@connect-shared/*": [ + "../../@connect-shared/*" + ], + "apiClient/*": [ + "./apiClient/*" + ], + "components/*": [ + "./components/*" + ], + "hooks/*": [ + "./hooks/*" + ], + "lib/*": [ + "./lib/*" + ], + "public/*": [ + "./public/*" + ], + "theme/*": [ + "./theme/*" + ], + "@root/abis/*": [ + "../../abis/*" + ], + "@root/adapters/*": [ + "../../adapters/*" + ], + "@root/config/*": [ + "../../config/*" + ], + "@root/connectors/*": [ + "../../connectors/*" + ], + "@root/lib/*": [ + "../../lib/*" + ], + "@root/hooks/*": [ + "../../hooks/*" + ], + "@charmverse/core/prisma": [ + "../../node_modules/@charmverse/core/dist/cjs/prisma" + ], + "@charmverse/core/prisma-client": [ + "../../node_modules/@charmverse/core/dist/cjs/prisma-client" + ], + "@charmverse/core/shared": [ + "../../node_modules/@charmverse/core/dist/cjs/shared" + ], + "@charmverse/core/log": [ + "../../node_modules/@charmverse/core/dist/cjs/lib/log" + ], + "@charmverse/core/http": [ + "../../node_modules/@charmverse/core/dist/cjs/http" + ], + "@charmverse/core/test": [ + "../../node_modules/@charmverse/core/dist/cjs/test" + ], + "@charmverse/core/permissions": [ + "../../node_modules/@charmverse/core/dist/cjs/permissions" + ], + "@charmverse/core/permissions/flags": [ + "../../node_modules/@charmverse/core/dist/cjs/permissions-flags" + ], + "@charmverse/core/pages": [ + "../../node_modules/@charmverse/core/dist/cjs/pages" + ], + "@charmverse/core/pages/utilities": [ + "../../node_modules/@charmverse/core/dist/cjs/pages-utilities" + ], + "@charmverse/core/proposals": [ + "../../node_modules/@charmverse/core/dist/cjs/proposals" + ], + "@charmverse/core/bounties": [ + "../../node_modules/@charmverse/core/dist/cjs/bounties" + ], + "@charmverse/core/utilities": [ + "../../node_modules/@charmverse/core/dist/cjs/utilities" + ], + "@charmverse/core/errors": [ + "../../node_modules/@charmverse/core/dist/cjs/errors" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["../../node_modules", ".next/", "public/sw.js", "sw.js"] -} + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "../../node_modules", + ".next/", + "public/sw.js", + "sw.js" + ] +} \ No newline at end of file diff --git a/apps/scoutgamecron/cron.yml b/apps/scoutgamecron/cron.yml index 667c9ae62e..04705f354c 100644 --- a/apps/scoutgamecron/cron.yml +++ b/apps/scoutgamecron/cron.yml @@ -1,26 +1,31 @@ version: 1 cron: - - name: 'process-builder-activity' - url: '/process-builder-activity' + - name: "process-builder-activity" + url: "/process-builder-activity" # every 20 minutes - schedule: '*/20 * * * *' + schedule: "*/20 * * * *" - - name: 'process-gems-payout' - url: '/process-gems-payout' + - name: "process-gems-payout" + url: "/process-gems-payout" # Start of every hour - schedule: '0 * * * *' + schedule: "0 * * * *" - - name: 'process-nft-mints' - url: '/process-nft-mints' + - name: "process-nft-mints" + url: "/process-nft-mints" # Every 5 minutes - schedule: '*/20 * * * *' + schedule: "*/20 * * * *" - - name: 'update-mixpanel-user-profiles' - url: '/update-mixpanel-user-profiles' + - name: "update-mixpanel-user-profiles" + url: "/update-mixpanel-user-profiles" # Every day at midnight - schedule: '0 0 * * *' + schedule: "0 0 * * *" - - name: 'alert-low-wallet-gas-balance' - url: '/alert-low-wallet-gas-balance' + - name: "alert-low-wallet-gas-balance" + url: "/alert-low-wallet-gas-balance" # Every 5 minutes - schedule: '*/5 * * * *' + schedule: "*/5 * * * *" + + - name: "send-points-claim-emails" + url: "/send-points-claim-emails" + # Every Monday at 00:00 + schedule: "0 0 * * 1" diff --git a/apps/scoutgamecron/package.json b/apps/scoutgamecron/package.json index 420d67d090..a6fddf5d87 100644 --- a/apps/scoutgamecron/package.json +++ b/apps/scoutgamecron/package.json @@ -17,6 +17,7 @@ "@octokit/plugin-throttling": "^9.3.1", "@packages/farcaster": "^0.0.0", "@packages/github": "^0.0.0", + "@packages/mailer": "^0.0.0", "@packages/mixpanel": "^0.0.0", "@packages/onchain": "^0.0.0", "@packages/scoutgame": "^0.0.0", diff --git a/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/sendPointsClaimEmails.ts b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/sendPointsClaimEmails.ts new file mode 100644 index 0000000000..375e08a156 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/sendPointsClaimEmails.ts @@ -0,0 +1,49 @@ +import { log } from '@charmverse/core/log'; +import { prisma } from '@charmverse/core/prisma-client'; +import { sendEmail } from '@packages/mailer/mailer'; +import { getClaimablePoints } from '@packages/scoutgame/points/getClaimablePoints'; +import { render } from '@react-email/render'; + +import { ClaimPointsTemplate } from './templates/ClaimPointsTemplate'; + +export async function sendPointsClaimEmails() { + const scouts = await prisma.scout.findMany({ + where: { + email: { + not: null + } + }, + select: { + id: true, + username: true, + displayName: true, + email: true + } + }); + + for (const scout of scouts) { + try { + const pointsToClaim = await getClaimablePoints(scout.id); + + if (pointsToClaim.totalClaimablePoints) { + const html = await render( + ClaimPointsTemplate({ points: pointsToClaim.totalClaimablePoints, displayName: scout.displayName }) + ); + await sendEmail({ + to: { + displayName: scout.displayName, + email: scout.email!, + userId: scout.id + }, + senderAddress: `The Scout Game `, + subject: 'Congratulations you just earned points in the Scout Game', + html + }); + } + } catch (error) { + log.error('Error sending points claim email', { error, scoutId: scout.id }); + } + } +} + +sendPointsClaimEmails(); diff --git a/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/ClaimPointsTemplate.tsx b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/ClaimPointsTemplate.tsx new file mode 100644 index 0000000000..9c18c76128 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/ClaimPointsTemplate.tsx @@ -0,0 +1,51 @@ +import { Head } from '@react-email/head'; +import { Html } from '@react-email/html'; +import { Img } from '@react-email/img'; +import { Section } from '@react-email/section'; +import React from 'react'; + +import Button from './components/Button'; +import Text from './components/Text'; + +const lightGreyColor = '#edf2f4'; + +export function ClaimPointsTemplate({ points, displayName }: { points: number; displayName: string }) { + return ( + + + Congratulations you just earned points in the Scout Game + +
+
+ +
+ + Congratulations, {displayName} you just earned {points} points this week in the Scout Game. + + +
+
+
+ + ); +} diff --git a/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Button.tsx b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Button.tsx new file mode 100644 index 0000000000..e9a2013758 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Button.tsx @@ -0,0 +1,40 @@ +import type { LinkProps } from '@react-email/link'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import Link from './Link'; +import Text from './Text'; + +export default function Button({ + children, + href, + style, + ...props +}: { + children: ReactNode; + href: string; +} & LinkProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Link.tsx b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Link.tsx new file mode 100644 index 0000000000..07f0d7c2f3 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Link.tsx @@ -0,0 +1,28 @@ +import type { LinkProps } from '@react-email/link'; +import { Link as ReactEmailLink } from '@react-email/link'; +import type { ReactNode } from 'react'; +import React from 'react'; + +export default function Link({ + primaryColor, + children, + ...props +}: { + primaryColor?: string; + children: ReactNode; +} & LinkProps) { + return ( + + {children} + + ); +} diff --git a/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Text.tsx b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Text.tsx new file mode 100644 index 0000000000..bbc7909960 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/sendPointsClaimEmails/templates/components/Text.tsx @@ -0,0 +1,67 @@ +import type { TextProps } from '@react-email/text'; +import { Text as ReactEmailText } from '@react-email/text'; +import type { CSSProperties, ReactNode } from 'react'; +import React from 'react'; + +// Defining the primary color directly here as the following error occurs when importing from theme/colors +// Error: Attempted to call darken() from the server but darken is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component. +export const primaryTextColor = '#37352f'; + +// copied from theme/fonts.ts because next/fonts doesnt play well with tsx or ts-node. TODO: maybe remove next/fonts? +const defaultFont = + 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"'; + +type TextVariant = 'body1' | 'h3' | 'subtitle1'; + +const TextStyleConfig: Record = { + body1: { + fontSize: 18 + }, + h3: { + fontSize: 20, + fontWeight: 'bold' + }, + subtitle1: { + fontSize: 16, + opacity: 0.65 + } +}; + +export default function Text({ + children, + variant = 'body1', + hideOverflow = false, + style = {}, + bold = false, + ...props +}: { + bold?: boolean; + primaryColor?: string; + hideOverflow?: boolean; + variant?: TextVariant; + children: ReactNode; +} & TextProps) { + return ( + + {children} + + ); +} diff --git a/apps/scoutgamecron/src/worker.ts b/apps/scoutgamecron/src/worker.ts index a3c24e4b6c..90a3a8f734 100644 --- a/apps/scoutgamecron/src/worker.ts +++ b/apps/scoutgamecron/src/worker.ts @@ -9,6 +9,7 @@ import { processAllBuilderActivity } from './tasks/processBuilderActivity'; import { processGemsPayout } from './tasks/processGemsPayout'; import { processNftMints } from './tasks/processNftMints'; import { sendNotifications } from './tasks/pushNotifications/sendNotifications'; +import { sendPointsClaimEmails } from './tasks/sendPointsClaimEmails/sendPointsClaimEmails'; import { updateMixpanelUserProfilesTask } from './tasks/updateMixpanelProfilesTask'; const app = new Koa(); @@ -57,6 +58,8 @@ addTask('/update-mixpanel-user-profiles', updateMixpanelUserProfilesTask); addTask('/alert-low-wallet-gas-balance', alertLowWalletGasBalance); +addTask('/send-points-claim-emails', sendPointsClaimEmails); + // Standard health check used by Beanstalk router.get('/api/health', middleware.healthCheck); diff --git a/package-lock.json b/package-lock.json index d5aee89c56..84f98877fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1034,6 +1034,7 @@ "@octokit/plugin-throttling": "^9.3.1", "@packages/farcaster": "^0.0.0", "@packages/github": "^0.0.0", + "@packages/mailer": "^0.0.0", "@packages/mixpanel": "^0.0.0", "@packages/onchain": "^0.0.0", "@packages/scoutgame": "^0.0.0", @@ -17640,6 +17641,10 @@ "resolved": "packages/github", "link": true }, + "node_modules/@packages/mailer": { + "resolved": "packages/mailer", + "link": true + }, "node_modules/@packages/mixpanel": { "resolved": "packages/mixpanel", "link": true @@ -68279,6 +68284,10 @@ "@octokit/plugin-paginate-graphql": "^5.2.2" } }, + "packages/mailer": { + "name": "@packages/mailer", + "version": "0.0.0" + }, "packages/mixpanel": { "name": "@packages/mixpanel", "version": "0.0.0" @@ -80747,6 +80756,9 @@ "@octokit/plugin-paginate-graphql": "^5.2.2" } }, + "@packages/mailer": { + "version": "file:packages/mailer" + }, "@packages/mixpanel": { "version": "file:packages/mixpanel" }, @@ -113547,6 +113559,7 @@ "@octokit/plugin-throttling": "^9.3.1", "@packages/farcaster": "^0.0.0", "@packages/github": "^0.0.0", + "@packages/mailer": "^0.0.0", "@packages/mixpanel": "^0.0.0", "@packages/onchain": "^0.0.0", "@packages/scoutgame": "^0.0.0", diff --git a/packages/mailer/package.json b/packages/mailer/package.json new file mode 100644 index 0000000000..89c682feaf --- /dev/null +++ b/packages/mailer/package.json @@ -0,0 +1,12 @@ +{ + "name": "@packages/mailer", + "version": "0.0.0", + "description": "", + "private": true, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "exports": { + "./*": "./src/*.ts" + } +} \ No newline at end of file diff --git a/packages/mailer/src/mailer.ts b/packages/mailer/src/mailer.ts new file mode 100644 index 0000000000..a81b34a7c9 --- /dev/null +++ b/packages/mailer/src/mailer.ts @@ -0,0 +1,40 @@ +import { log } from '@charmverse/core/log'; +import { htmlToText } from 'html-to-text'; +import type { IMailgunClient } from 'mailgun.js/Interfaces'; + +import mailgunClient, { DOMAIN, SENDER_ADDRESS } from './mailgunClient'; + +export interface EmailRecipient { + email: string; + displayName?: string | null; + userId: string; +} + +interface EmailProps { + html: string; + subject: string; + to: EmailRecipient; + attachment?: { data: Buffer; name: string }; + senderAddress?: string; + client?: IMailgunClient | null; +} + +export async function sendEmail({ client, html, subject, to, attachment, senderAddress }: EmailProps) { + const recipientAddress = to.displayName ? `${to.displayName} <${to.email}>` : to.email; + client = client ?? mailgunClient; + + if (!client) { + log.debug('No mailgun client, not sending email'); + } else { + log.debug('Sending email to Mailgun', { subject, userId: to.userId }); + } + + return client?.messages.create(DOMAIN, { + from: senderAddress ?? SENDER_ADDRESS, + to: [recipientAddress], + subject, + text: htmlToText(html), + html, + attachment: attachment ? { data: attachment.data, filename: attachment.name } : undefined + }); +} diff --git a/packages/mailer/src/mailgunClient.ts b/packages/mailer/src/mailgunClient.ts new file mode 100644 index 0000000000..4c844179bb --- /dev/null +++ b/packages/mailer/src/mailgunClient.ts @@ -0,0 +1,10 @@ +import formData from 'form-data'; +import Mailgun from 'mailgun.js'; + +export const API_KEY = process.env.MAILGUN_API_KEY as string | undefined; +export const DOMAIN = process.env.MAILGUN_DOMAIN as string; +export const SENDER_ADDRESS = `CharmVerse `; +const mailgun = new Mailgun(formData); +const client = API_KEY && API_KEY !== 'test-key' ? mailgun.client({ username: 'api', key: API_KEY }) : null; + +export default client; diff --git a/packages/mailer/tsconfig.json b/packages/mailer/tsconfig.json new file mode 100644 index 0000000000..f5cf4de8e2 --- /dev/null +++ b/packages/mailer/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + // composite is useful for referenced packages. see https://www.typescriptlang.org/docs/handbook/project-references.html#composite + "composite": true + } +} diff --git a/apps/scoutgame/lib/points/getClaimablePoints.ts b/packages/scoutgame/src/points/getClaimablePoints.ts similarity index 91% rename from apps/scoutgame/lib/points/getClaimablePoints.ts rename to packages/scoutgame/src/points/getClaimablePoints.ts index 1ac3072c2a..91fd7f70cc 100644 --- a/apps/scoutgame/lib/points/getClaimablePoints.ts +++ b/packages/scoutgame/src/points/getClaimablePoints.ts @@ -1,5 +1,6 @@ import { prisma } from '@charmverse/core/prisma-client'; -import { currentSeason, getPreviousSeason } from '@packages/scoutgame/dates'; + +import { currentSeason, getPreviousSeason } from '../dates'; export async function getClaimablePoints(userId: string): Promise<{ totalClaimablePoints: number }> { const previousSeason = getPreviousSeason(currentSeason);