From ad5224820be1666bfafb22cd4612f8fe99777893 Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 16:46:35 -0800 Subject: [PATCH 1/8] feat: Add Zoom reminder email component --- src/components/emails/ZoomReminder.tsx | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/components/emails/ZoomReminder.tsx diff --git a/src/components/emails/ZoomReminder.tsx b/src/components/emails/ZoomReminder.tsx new file mode 100644 index 0000000..b9f3ac8 --- /dev/null +++ b/src/components/emails/ZoomReminder.tsx @@ -0,0 +1,118 @@ +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text, +} from "@react-email/components"; + +import imgurLogoImageUrl from "@/utils/constants/imgurLogoImageUrl"; + +type ZoomReminderEmailProps = { + meetingName?: string; +}; + +export default function ZoomReminderEmail({ + meetingName = "Healthy Habits", +}: ZoomReminderEmailProps) { + return ( + + + Reminder: You have an upcoming {meetingName} meeting + + +
+ St. Christopher Truckers Relief Fund + + Please click the link below to join the zoom call for{" "} + {meetingName} for the Long Haul + + + + If you have any questions or concerns, please reach out to us at + our{" "} + + contact page. + + +
+ + St. Christopher Truckers Relief Fund, Phone: (865) 202-9428 + +
+
+ + + ); +} + +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", + marginTop: "64px", +}; + +const box = { + padding: "0 48px", +}; + +const hr = { + borderColor: "#e6ebf1", + margin: "20px 0", +}; + +const paragraph = { + color: "#525f7f", + fontSize: "16px", + lineHeight: "24px", + textAlign: "left" as const, +}; + +const anchor = { + color: "#556cd6", +}; + +const button = { + backgroundColor: "#183766", + borderRadius: "5px", + color: "#fff", + fontSize: "16px", + fontWeight: "bold", + textDecoration: "none", + textAlign: "center" as const, + display: "block", + width: "100%", + padding: "10px", +}; + +const footer = { + color: "#8898aa", + fontSize: "12px", + lineHeight: "16px", +}; + +const image = { + margin: "0 auto", +}; From f6c7f0989830b45d618c8539b40abcfdb8cbf7c9 Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 16:50:31 -0800 Subject: [PATCH 2/8] fix: Changed default name for meetingName, added meetingLink prop --- src/components/emails/ZoomReminder.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/emails/ZoomReminder.tsx b/src/components/emails/ZoomReminder.tsx index b9f3ac8..26fb791 100644 --- a/src/components/emails/ZoomReminder.tsx +++ b/src/components/emails/ZoomReminder.tsx @@ -16,10 +16,12 @@ import imgurLogoImageUrl from "@/utils/constants/imgurLogoImageUrl"; type ZoomReminderEmailProps = { meetingName?: string; + meetingLink?: string; }; export default function ZoomReminderEmail({ - meetingName = "Healthy Habits", + meetingName = "your SCF program", + meetingLink = "https://www.google.com", }: ZoomReminderEmailProps) { return ( @@ -37,9 +39,9 @@ export default function ZoomReminderEmail({ /> Please click the link below to join the zoom call for{" "} - {meetingName} for the Long Haul + {meetingName}. - From c9b8c28130fda283b5051397ce9c9139016ae2bc Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 16:59:58 -0800 Subject: [PATCH 3/8] feat: Implement Zoom reminder email component and integrate into email sending functionality --- .../{ZoomReminder.tsx => ZoomReminderEmail.tsx} | 0 src/server/api/emails/private-mutations.tsx | 17 +++++++++++++++++ 2 files changed, 17 insertions(+) rename src/components/emails/{ZoomReminder.tsx => ZoomReminderEmail.tsx} (100%) diff --git a/src/components/emails/ZoomReminder.tsx b/src/components/emails/ZoomReminderEmail.tsx similarity index 100% rename from src/components/emails/ZoomReminder.tsx rename to src/components/emails/ZoomReminderEmail.tsx diff --git a/src/server/api/emails/private-mutations.tsx b/src/server/api/emails/private-mutations.tsx index 72c017e..89059cc 100644 --- a/src/server/api/emails/private-mutations.tsx +++ b/src/server/api/emails/private-mutations.tsx @@ -5,6 +5,7 @@ import PasswordChangedEmail from "@/components/emails/PasswordChangedEmail"; import RejectionEmail from "@/components/emails/RejectionEmail"; import ResetPasswordEmail from "@/components/emails/ResetPasswordEmail"; import WelcomeEmail from "@/components/emails/WelcomeEmail"; +import ZoomReminderEmail from "@/components/emails/ZoomReminderEmail"; import sendEmail from "./helpers"; @@ -62,3 +63,19 @@ export async function sendEmailVerificationEmail( await sendEmail(recipientEmail, "Verify your SCF email address", html); } + +export async function sendZoomReminderEmail( + recipientEmail: string, + meetingName: string, + meetingLink: string, +) { + const html = await render( + , + ); + + await sendEmail( + recipientEmail, + `Reminder: You have an upcoming meeting for ${meetingName}.`, + html, + ); +} From 8fa406e6a1f8d401060f0376742bcd2fb6437d6c Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 18:03:08 -0800 Subject: [PATCH 4/8] feat: Add API endpoint for sending Zoom reminder emails with validation --- .../send-healthy-habits-zoom-link/route.ts | 61 +++++++++++++++++++ src/server/api/emails/private-mutations.tsx | 14 +++-- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts diff --git a/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts b/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts new file mode 100644 index 0000000..a88a4a7 --- /dev/null +++ b/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { sendZoomReminderEmail } from "@/server/api/emails/private-mutations"; + +const sendZoomReminderEmailRequestSchema = z.object({ + recipientEmail: z.string().email({ message: "Invalid email" }), + meetingName: z.string(), + meetingLink: z.string(), +}); + +export type SendZoomReminderEmailRequest = z.infer< + typeof sendZoomReminderEmailRequestSchema +>; + +export async function POST(request: Request) { + try { + const json = await request.json(); + + const apiKeyHeader = request.headers.get("x-api-key"); + + if (!apiKeyHeader || apiKeyHeader !== process.env.API_KEY) { + return new Response( + JSON.stringify({ success: false, error: "Invalid request." }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const parsedJson = sendZoomReminderEmailRequestSchema.safeParse(json); + + if (parsedJson.success === false) { + return new Response( + JSON.stringify({ success: false, error: "Invalid request body." }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const sendZoomReminderEmailRequest = parsedJson.data; + + await sendZoomReminderEmail(sendZoomReminderEmailRequest); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error(error); + return new Response( + JSON.stringify({ success: false, error: "Internal server error." }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } +} diff --git a/src/server/api/emails/private-mutations.tsx b/src/server/api/emails/private-mutations.tsx index 89059cc..b3babdf 100644 --- a/src/server/api/emails/private-mutations.tsx +++ b/src/server/api/emails/private-mutations.tsx @@ -1,5 +1,6 @@ import { render } from "@react-email/components"; +import { SendZoomReminderEmailRequest } from "@/app/api/emails/actions/send-healthy-habits-zoom-link/route"; import EmailVerificationEmail from "@/components/emails/EmailVerificationEmail"; import PasswordChangedEmail from "@/components/emails/PasswordChangedEmail"; import RejectionEmail from "@/components/emails/RejectionEmail"; @@ -65,17 +66,18 @@ export async function sendEmailVerificationEmail( } export async function sendZoomReminderEmail( - recipientEmail: string, - meetingName: string, - meetingLink: string, + sendZoomReminderEmailRequest: SendZoomReminderEmailRequest, ) { const html = await render( - , + , ); await sendEmail( - recipientEmail, - `Reminder: You have an upcoming meeting for ${meetingName}.`, + sendZoomReminderEmailRequest.recipientEmail, + `Reminder: You have an upcoming meeting for: ${sendZoomReminderEmailRequest.meetingName}.`, html, ); } From 66a655e7386d2da7ef717b46a3a44bc68ffe26f4 Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 18:26:23 -0800 Subject: [PATCH 5/8] feat: Add GitHub Actions workflow for weekly Zoom reminder emails --- .github/workflows/weekly-zoom-reminder.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/weekly-zoom-reminder.yml diff --git a/.github/workflows/weekly-zoom-reminder.yml b/.github/workflows/weekly-zoom-reminder.yml new file mode 100644 index 0000000..c1b7a53 --- /dev/null +++ b/.github/workflows/weekly-zoom-reminder.yml @@ -0,0 +1,16 @@ +name: Weekly Zoom Reminder +on: + schedule: + # Runs at 12:00 PM UTC (noon) every Sunday + - cron: '0 12 * * 0' + +jobs: + send-reminders: + runs-on: ubuntu-latest + steps: + - name: Send Zoom Reminder Emails + run: | + curl -X POST \ + https://rudra-utkscf-fork.vercel.app/api/emails/actions/send-healthy-habits-zoom-link \ + -H "x-api-key: ${{ secrets.API_KEY }}" \ + -H "Content-Type: application/json" \ No newline at end of file From 94533b7b2f91bac82d79c46334400de6fee32312 Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 19:05:09 -0800 Subject: [PATCH 6/8] feat: Refactor Zoom reminder email sending to retrieve users by program and simplify request handling --- .../send-healthy-habits-zoom-link/route.ts | 37 ++++++++----------- src/server/api/emails/private-mutations.tsx | 14 +++---- src/server/api/users/private-mutations.ts | 12 +++++- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts b/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts index a88a4a7..063dc29 100644 --- a/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts +++ b/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts @@ -1,21 +1,8 @@ -import { z } from "zod"; - import { sendZoomReminderEmail } from "@/server/api/emails/private-mutations"; - -const sendZoomReminderEmailRequestSchema = z.object({ - recipientEmail: z.string().email({ message: "Invalid email" }), - meetingName: z.string(), - meetingLink: z.string(), -}); - -export type SendZoomReminderEmailRequest = z.infer< - typeof sendZoomReminderEmailRequestSchema ->; +import { getUsersByProgram } from "@/server/api/users/private-mutations"; export async function POST(request: Request) { try { - const json = await request.json(); - const apiKeyHeader = request.headers.get("x-api-key"); if (!apiKeyHeader || apiKeyHeader !== process.env.API_KEY) { @@ -28,21 +15,29 @@ export async function POST(request: Request) { ); } - const parsedJson = sendZoomReminderEmailRequestSchema.safeParse(json); + const [users, usersError] = await getUsersByProgram( + "Healthy Habits For The Long Haul", + ); - if (parsedJson.success === false) { + if (usersError !== null) { return new Response( - JSON.stringify({ success: false, error: "Invalid request body." }), + JSON.stringify({ success: false, error: "Internal server error." }), { - status: 400, + status: 500, headers: { "Content-Type": "application/json" }, }, ); } - const sendZoomReminderEmailRequest = parsedJson.data; - - await sendZoomReminderEmail(sendZoomReminderEmailRequest); + await Promise.all( + users.map((user) => + sendZoomReminderEmail( + "Healthy Habits For The Long Haul", + "https://google.com", + user.email, + ), + ), + ); return new Response(JSON.stringify({ success: true }), { status: 200, diff --git a/src/server/api/emails/private-mutations.tsx b/src/server/api/emails/private-mutations.tsx index b3babdf..e8add10 100644 --- a/src/server/api/emails/private-mutations.tsx +++ b/src/server/api/emails/private-mutations.tsx @@ -1,6 +1,5 @@ import { render } from "@react-email/components"; -import { SendZoomReminderEmailRequest } from "@/app/api/emails/actions/send-healthy-habits-zoom-link/route"; import EmailVerificationEmail from "@/components/emails/EmailVerificationEmail"; import PasswordChangedEmail from "@/components/emails/PasswordChangedEmail"; import RejectionEmail from "@/components/emails/RejectionEmail"; @@ -66,18 +65,17 @@ export async function sendEmailVerificationEmail( } export async function sendZoomReminderEmail( - sendZoomReminderEmailRequest: SendZoomReminderEmailRequest, + meetingName: string, + meetingLink: string, + recipientEmail: string, ) { const html = await render( - , + , ); await sendEmail( - sendZoomReminderEmailRequest.recipientEmail, - `Reminder: You have an upcoming meeting for: ${sendZoomReminderEmailRequest.meetingName}.`, + recipientEmail, + `Reminder: You have an upcoming meeting for ${meetingName}.`, html, ); } diff --git a/src/server/api/users/private-mutations.ts b/src/server/api/users/private-mutations.ts index 62b1ea3..46823fc 100644 --- a/src/server/api/users/private-mutations.ts +++ b/src/server/api/users/private-mutations.ts @@ -4,7 +4,7 @@ import mongoose from "mongoose"; import { AdminUserRequest } from "@/app/api/users/actions/create-admin-account/route"; import dbConnect from "@/server/dbConnect"; import { UserModel } from "@/server/models"; -import { AdminUser, ApiResponse, ClientUser, User } from "@/types"; +import { AdminUser, ApiResponse, ClientUser, Program, User } from "@/types"; import authenticateServerFunction from "@/utils/authenticateServerFunction"; import apiErrors from "@/utils/constants/apiErrors"; import dayjsUtil from "@/utils/dayjsUtil"; @@ -138,3 +138,13 @@ export async function changePassword( return [null, null]; } + +export async function getUsersByProgram( + program: Program, +): Promise> { + await dbConnect(); + + const users = await UserModel.find({ program: program }).lean().exec(); + + return [users, null]; +} From 58e158e930fc44d5fc3989f2a867b70b22348f70 Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 20:10:21 -0800 Subject: [PATCH 7/8] refactor: Update getUsersByProgram to use program name and handle errors --- .../send-healthy-habits-zoom-link/route.ts | 5 ++- src/server/api/users/private-mutations.ts | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts b/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts index 063dc29..dddfd1a 100644 --- a/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts +++ b/src/app/api/emails/actions/send-healthy-habits-zoom-link/route.ts @@ -15,9 +15,8 @@ export async function POST(request: Request) { ); } - const [users, usersError] = await getUsersByProgram( - "Healthy Habits For The Long Haul", - ); + const programName = "Healthy Habits For The Long Haul"; + const [users, usersError] = await getUsersByProgram(programName); if (usersError !== null) { return new Response( diff --git a/src/server/api/users/private-mutations.ts b/src/server/api/users/private-mutations.ts index 46823fc..a149a6a 100644 --- a/src/server/api/users/private-mutations.ts +++ b/src/server/api/users/private-mutations.ts @@ -3,8 +3,14 @@ import mongoose from "mongoose"; import { AdminUserRequest } from "@/app/api/users/actions/create-admin-account/route"; import dbConnect from "@/server/dbConnect"; -import { UserModel } from "@/server/models"; -import { AdminUser, ApiResponse, ClientUser, Program, User } from "@/types"; +import { ProgramEnrollmentModel, UserModel } from "@/server/models"; +import { + AdminUser, + ApiResponse, + ClientUser, + ProgramEnrollment, + User, +} from "@/types"; import authenticateServerFunction from "@/utils/authenticateServerFunction"; import apiErrors from "@/utils/constants/apiErrors"; import dayjsUtil from "@/utils/dayjsUtil"; @@ -140,11 +146,26 @@ export async function changePassword( } export async function getUsersByProgram( - program: Program, + programName: string, ): Promise> { await dbConnect(); - const users = await UserModel.find({ program: program }).lean().exec(); - - return [users, null]; + try { + // Find program enrollments for the given program and populate the associated users + const enrollments = await ProgramEnrollmentModel.find({ + program: programName, + }) + .populate("user") + .lean() + .exec(); + + const users = enrollments.map( + (enrollment: ProgramEnrollment) => enrollment.user, + ); + + return [users, null]; + } catch (error) { + console.error(error); + return [null, "Error fetching users by program."]; + } } From ccaf09dbc35092e0711e6787db8fdbd3d41cd9cf Mon Sep 17 00:00:00 2001 From: Alan Khalili Date: Wed, 15 Jan 2025 20:18:59 -0800 Subject: [PATCH 8/8] fix: filter program enrollments to only accept accepted people --- src/server/api/users/private-mutations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/api/users/private-mutations.ts b/src/server/api/users/private-mutations.ts index a149a6a..8002351 100644 --- a/src/server/api/users/private-mutations.ts +++ b/src/server/api/users/private-mutations.ts @@ -154,6 +154,7 @@ export async function getUsersByProgram( // Find program enrollments for the given program and populate the associated users const enrollments = await ProgramEnrollmentModel.find({ program: programName, + status: "accepted", }) .populate("user") .lean()