Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Birthday Reminders #106

Merged
merged 13 commits into from
Mar 14, 2025
70 changes: 41 additions & 29 deletions app/api/emails/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { sendFollowUpMail, sendLoggingMail } from "@/lib/email";
import { QueueEmail, sendEmails } from "@/lib/email";
import { isSameDay, parseISO, subMonths } from "date-fns";
import { toZonedTime } from "date-fns-tz";
import { getActiveMembers } from "lib/easyverein";
import { getActiveMembers, Member } from "lib/easyverein";
import { isBirthday } from "lib/utils";
import { NextRequest } from "next/server";

const { CRON_SECRET, NODE_ENV } = process.env;
Expand All @@ -17,43 +18,54 @@ export async function GET(request: NextRequest) {
}
}

const emailQueue: Array<QueueEmail> = [];

const members = await getActiveMembers();
const birthdayMembers: Array<Member> = [];

const oneMonthAgo = toZonedTime(subMonths(new Date(), 1), "UTC");

for (const member of members) {
if (!member.joinDate) {
continue;
}
if (member.contactDetails.dateOfBirth) {
const dateOfBirth = toZonedTime(
parseISO(member.contactDetails.dateOfBirth),
"UTC",
);

const joinDate = toZonedTime(parseISO(member.joinDate), "UTC");
if (isBirthday(dateOfBirth)) {
birthdayMembers.push(member);
}
}

// Follow up mail after 1 month
if (isSameDay(joinDate, oneMonthAgo)) {
try {
const error = await sendFollowUpMail({
email: member.emailOrUserName,
name: member.contactDetails.firstName,
});
if (member.joinDate) {
const joinDate = toZonedTime(parseISO(member.joinDate), "UTC");

if (error) {
throw new Error(error.message, { cause: error });
}

console.info(`Sent follow up mail to ${member.emailOrUserName}`);
} catch (error) {
console.error(
`Failed to send follow up mail to ${member.emailOrUserName}:`,
error,
);
await sendLoggingMail({
subject: "Failed to send follow up mail",
text: `Failed to send follow up mail to ${member.emailOrUserName}.`,
if (isSameDay(joinDate, oneMonthAgo)) {
emailQueue.push({
type: "followUp",
args: {
name: member.contactDetails.firstName,
email: member.emailOrUserName,
},
});
}
}
}

return Response.json({
success: true,
});
if (birthdayMembers.length > 0) {
emailQueue.push({
type: "birthdayNotification",
args: { members: birthdayMembers },
});
}

if (NODE_ENV === "production") {
if (emailQueue.length !== 0) {
await sendEmails(emailQueue);
}
} else {
console.info("Emails: ", emailQueue);
}

return Response.json({ success: true });
}
6 changes: 3 additions & 3 deletions components/admin/WelcomeEmailCard/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { createAction } from "@/lib/clients";
import { ActionError } from "@/lib/data/errors";
import { sendLoggingMail, sendWelcomeMail } from "@/lib/email";
import { sendLoggingEmail, sendWelcomeEmail } from "@/lib/email";
import { z } from "zod";

export const sendEmail = createAction({
Expand All @@ -13,10 +13,10 @@ export const sendEmail = createAction({
action: async ({ input }) => {
const { name, email } = input;

const error = await sendWelcomeMail({ name, email });
const error = await sendWelcomeEmail({ name, email });

if (error) {
await sendLoggingMail({
await sendLoggingEmail({
subject: "Failed to send welcome mail",
text: `Failed to send welcome mail to ${email}.`,
});
Expand Down
3 changes: 2 additions & 1 deletion lib/easyverein.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const memberSchema = z.object({
name: z.string(),
firstName: z.string(),
familyName: z.string(),
dateOfBirth: z.string().nullable(),
}),
emailOrUserName: z.string(),
customFields: z.array(customFieldSchema).nullable(),
Expand All @@ -99,7 +100,7 @@ const memberSchema = z.object({
export const getMembers = async () => {
return await get("member", z.array(memberSchema), {
query:
"{id,joinDate,resignationDate,_isApplication,_profilePicture,contactDetails{name,firstName,familyName},emailOrUserName,customFields{value,customField{name}}}",
"{id,joinDate,resignationDate,_isApplication,_profilePicture,contactDetails{name,firstName,familyName,dateOfBirth},emailOrUserName,customFields{value,customField{name}}}",
limit: 200,
});
};
Expand Down
117 changes: 88 additions & 29 deletions lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import FollowUpEmail from "@/emails/emails/FollowUpEmail";
import { parseISO } from "date-fns";
import WelcomeEmail from "emails/emails/WelcomeEmail";
import { Resend } from "resend";
import { Member } from "./easyverein";
import { getNextEvent, WebsiteEvent } from "./events";
import { formatDate } from "./utils";

Expand All @@ -10,9 +11,21 @@ const { RESEND_API_KEY } = process.env;
const resend = new Resend(RESEND_API_KEY);

const DEFAULT_FROM = "Nina von der Makers League <[email protected]>";
const DEFAULT_BCC = ["[email protected]", "[email protected]"];

export const sendLoggingMail = async ({
const getStammtischData = (event: WebsiteEvent) => {
if (!event.start || !event.url) return;

const startDate =
typeof event.start === "string" ? parseISO(event.start) : event.start;
const formatedDate = formatDate(startDate, "dd.MM.");

return {
date: formatedDate,
url: event.url,
};
};

export const sendLoggingEmail = async ({
subject,
text,
}: {
Expand All @@ -29,20 +42,7 @@ export const sendLoggingMail = async ({
return error;
};

const getStammtischData = (event: WebsiteEvent) => {
if (!event.start || !event.url) return;

const startDate =
typeof event.start === "string" ? parseISO(event.start) : event.start;
const formatedDate = formatDate(startDate, "dd.MM.");

return {
date: formatedDate,
url: event.url,
};
};

export const sendWelcomeMail = async ({
export const sendWelcomeEmail = async ({
name,
email,
}: {
Expand All @@ -54,24 +54,18 @@ export const sendWelcomeMail = async ({
const { error } = await resend.emails.send({
from: DEFAULT_FROM,
to: [email],
bcc: DEFAULT_BCC,
bcc: ["[email protected]", "[email protected]"],
subject: "Herzlich Willkommen in der Makers League",
react: WelcomeEmail({
firstName: name,
nextStammtisch: event ? getStammtischData(event) : undefined,
}),
tags: [
{
name: "category",
value: "welcome_email",
},
],
});

return error;
};

export const sendFollowUpMail = async ({
const getFollowUpEmail = async ({
name,
email,
}: {
Expand All @@ -80,22 +74,87 @@ export const sendFollowUpMail = async ({
}) => {
const event = await getNextEvent("stammtisch");

const { error } = await resend.emails.send({
return {
from: DEFAULT_FROM,
to: [email],
bcc: DEFAULT_BCC,
bcc: ["[email protected]", "[email protected]"],
subject: "Dein Start bei der Makers League",
react: FollowUpEmail({
firstName: name,
nextStammtisch: event ? getStammtischData(event) : undefined,
}),
};
};

const getBirthdayNotificationEmail = async ({
members,
}: {
members: Array<Member>;
}) => {
const today = new Date();

const formattedMembersList = members
.map((member) => {
const birthDate = parseISO(member.contactDetails?.dateOfBirth || "");
const age = today.getFullYear() - birthDate.getFullYear();

return `- ${member.contactDetails.name} (wird heute ${age} Jahre alt) - ${member.emailOrUserName}`;
})
.join("\n");

const text = `Geburtstage heute:\n\n${formattedMembersList}`;

return {
from: DEFAULT_FROM,
to: ["[email protected]"],
bcc: ["[email protected]"],
subject: `🎂 ML-Geburtstage am ${formatDate(today, "dd.MM.yyyy")}`,
text,
tags: [
{
name: "category",
value: "follow_up_email",
value: "birthday_notification",
},
],
});
};
};

return error;
// Map of email types that can be send via the queue
const queueEmailsMap = {
followUp: getFollowUpEmail,
birthdayNotification: getBirthdayNotificationEmail,
} as const;

type QueueEmailsMap = typeof queueEmailsMap;
type QueueEmailType = keyof QueueEmailsMap;
export type QueueEmail = {
[K in QueueEmailType]: { type: K; args: Parameters<QueueEmailsMap[K]>[0] };
}[QueueEmailType];

export const sendEmails = async (emails: Array<QueueEmail>) => {
try {
const resendEmailPromises = emails.map((email) => {
// @ts-expect-error: Can' get the type right here
return queueEmailsMap[email.type](email.args);
});

const resendEmails = await Promise.all(resendEmailPromises);

const { error } = await resend.batch.send(resendEmails);

if (error) {
throw new Error(error.message, { cause: error });
}

await sendLoggingEmail({
subject: "Emails sent",
text: `Emails sent: ${JSON.stringify(emails, null, 2)}`,
});
} catch (error) {
console.error("Error sending emails:", error);
await sendLoggingEmail({
subject: "Error sending batch emails",
text: `Error sending batch emails: ${JSON.stringify(emails, null, 2)}`,
});
}
};
13 changes: 13 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,16 @@ export const wait = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

export const cn = cx;

/**
* Checks if a given date is today's birthday (comparing only month and day)
* @param birthDate - The birth date to check
* @param today - Optional reference date (defaults to current date)
* @returns boolean indicating if it's the birthday
*/
export const isBirthday = (birthDate: Date, today: Date = new Date()): boolean => {
return (
today.getDate() === birthDate.getDate() &&
today.getMonth() === birthDate.getMonth()
);
};