Skip to content

Commit dd9081e

Browse files
committed
refactor(emails): react-emails
1 parent 893bf65 commit dd9081e

10 files changed

Lines changed: 2195 additions & 138 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
/.next/
1616
/out/
1717

18+
/.react-email
19+
1820
# production
1921
/build
2022

app/coaching/notifications.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import "server-only";
2+
3+
import { asc, eq } from "drizzle-orm";
4+
import { pgSchema, uuid, text } from "drizzle-orm/pg-core";
5+
6+
import { db } from "@/lib/db";
7+
import {
8+
children,
9+
coachingSessions,
10+
emergencyContacts,
11+
formQuestionAnswers,
12+
formQuestions,
13+
profiles,
14+
} from "@/lib/db/schema";
15+
import { getService } from "@/app/(authenticated)/services/queries";
16+
import { sendEmail } from "@/lib/email/client";
17+
import { coordinatorBookingEmail, type ChildInfo } from "@/lib/email/templates";
18+
import { userHasActiveSubscription } from "@/lib/stripe";
19+
import type { TimeSlot } from "@/lib/scheduling/time-slot";
20+
21+
const authSchema = pgSchema("auth");
22+
const authUsers = authSchema.table("users", {
23+
id: uuid("id").primaryKey(),
24+
email: text("email"),
25+
});
26+
27+
type ProfileWithEmail = {
28+
firstName: string;
29+
lastName: string;
30+
email: string | null;
31+
address: string | null;
32+
gender: string | null;
33+
dob: string | null;
34+
phone: string | null;
35+
};
36+
37+
async function getProfileWithEmail(
38+
profileId: string,
39+
): Promise<ProfileWithEmail | null> {
40+
const [row] = await db
41+
.select({
42+
firstName: profiles.firstName,
43+
lastName: profiles.lastName,
44+
email: authUsers.email,
45+
address: profiles.address,
46+
gender: profiles.gender,
47+
dob: profiles.dob,
48+
phone: profiles.phone,
49+
})
50+
.from(profiles)
51+
.innerJoin(authUsers, eq(authUsers.id, profiles.id))
52+
.where(eq(profiles.id, profileId))
53+
.limit(1);
54+
return row ?? null;
55+
}
56+
57+
async function loadChildInfo(childId: string): Promise<ChildInfo | null> {
58+
const [child] = await db
59+
.select()
60+
.from(children)
61+
.where(eq(children.id, childId))
62+
.limit(1);
63+
if (!child) return null;
64+
65+
const contacts = await db
66+
.select({
67+
fullName: emergencyContacts.fullName,
68+
relationship: emergencyContacts.relationship,
69+
phoneNumber: emergencyContacts.phoneNumber,
70+
emailAddress: emergencyContacts.emailAddress,
71+
})
72+
.from(emergencyContacts)
73+
.where(eq(emergencyContacts.childId, childId));
74+
75+
const answers = await db
76+
.select({
77+
prompt: formQuestions.prompt,
78+
answer: formQuestionAnswers.answer,
79+
sortOrder: formQuestions.sortOrder,
80+
})
81+
.from(formQuestionAnswers)
82+
.innerJoin(
83+
formQuestions,
84+
eq(formQuestions.id, formQuestionAnswers.formQuestionId),
85+
)
86+
.where(eq(formQuestionAnswers.childId, childId))
87+
.orderBy(asc(formQuestions.sortOrder));
88+
89+
return {
90+
firstName: child.firstName,
91+
lastName: child.lastName,
92+
dob: child.dob,
93+
gender: child.gender,
94+
allergies: child.allergies,
95+
medicalConditions: child.medicalConditions,
96+
medications: child.medications,
97+
emergencyContacts: contacts,
98+
formAnswers: answers.map((a) => ({ prompt: a.prompt, answer: a.answer })),
99+
};
100+
}
101+
102+
export async function sendCoordinatorBookingEmail(
103+
sessionId: string,
104+
): Promise<void> {
105+
try {
106+
const [session] = await db
107+
.select()
108+
.from(coachingSessions)
109+
.where(eq(coachingSessions.id, sessionId))
110+
.limit(1);
111+
if (!session) {
112+
console.error(
113+
"[sendCoordinatorBookingEmail] session missing",
114+
sessionId,
115+
);
116+
return;
117+
}
118+
119+
const [coordinator, client, service, hasActiveSubscription] =
120+
await Promise.all([
121+
getProfileWithEmail(session.coordinatorId),
122+
getProfileWithEmail(session.userId),
123+
getService(session.serviceId),
124+
userHasActiveSubscription(session.userId),
125+
]);
126+
127+
if (!coordinator?.email) {
128+
console.error(
129+
"[sendCoordinatorBookingEmail] coordinator email missing",
130+
sessionId,
131+
);
132+
return;
133+
}
134+
if (!client) {
135+
console.error(
136+
"[sendCoordinatorBookingEmail] client profile missing",
137+
sessionId,
138+
);
139+
return;
140+
}
141+
142+
const serviceTitle = service?.title ?? "Private lesson";
143+
const durationMinutes = service?.durationMinutes ?? 60;
144+
145+
let scheduledSlot: TimeSlot | null = null;
146+
if (session.scheduledAt) {
147+
const start = session.scheduledAt;
148+
const end = new Date(start.getTime() + durationMinutes * 60_000);
149+
scheduledSlot = { start: start.toISOString(), end: end.toISOString() };
150+
}
151+
152+
const child = session.childId
153+
? await loadChildInfo(session.childId)
154+
: null;
155+
156+
const email = await coordinatorBookingEmail({
157+
coordinatorName:
158+
`${coordinator.firstName} ${coordinator.lastName}`.trim(),
159+
serviceTitle,
160+
client: {
161+
firstName: client.firstName,
162+
lastName: client.lastName,
163+
email: client.email ?? "—",
164+
address: client.address,
165+
gender: client.gender,
166+
dob: client.dob,
167+
phone: client.phone,
168+
hasActiveSubscription,
169+
},
170+
scheduledSlot,
171+
requestedAvailability:
172+
(session.selectedTimeSlots as TimeSlot[] | null) ?? [],
173+
notes: session.notes,
174+
child,
175+
});
176+
177+
await sendEmail({ to: coordinator.email, ...email });
178+
} catch (e) {
179+
console.error("[sendCoordinatorBookingEmail]", e);
180+
}
181+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { CoordinatorBookingEmail } from "../lib/email/coordinator-booking";
2+
3+
export default function AdultScheduledSubscribed() {
4+
return (
5+
<CoordinatorBookingEmail
6+
coordinatorName="Jordan Lee"
7+
serviceTitle="1:1 Skating Lesson"
8+
client={{
9+
firstName: "Alex",
10+
lastName: "Rivera",
11+
email: "alex.rivera@example.com",
12+
address: "1240 Rue Sainte-Catherine, Montréal, QC H3B 1A7",
13+
gender: "prefer_not_to_say",
14+
dob: "1992-09-30",
15+
phone: "+1 438-555-0110",
16+
hasActiveSubscription: true,
17+
}}
18+
scheduledSlot={{
19+
start: "2026-06-22T18:00:00.000Z",
20+
end: "2026-06-22T19:00:00.000Z",
21+
}}
22+
/>
23+
);
24+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { CoordinatorBookingEmail } from "../lib/email/coordinator-booking";
2+
3+
export default function AdultUnscheduledNoSubscription() {
4+
return (
5+
<CoordinatorBookingEmail
6+
coordinatorName="Jordan Lee"
7+
serviceTitle="1:1 Skating Lesson"
8+
client={{
9+
firstName: "Alex",
10+
lastName: "Rivera",
11+
email: "alex.rivera@example.com",
12+
address: "1240 Rue Sainte-Catherine, Montréal, QC H3B 1A7",
13+
gender: "prefer_not_to_say",
14+
dob: "1992-09-30",
15+
phone: "+1 438-555-0110",
16+
hasActiveSubscription: false,
17+
}}
18+
scheduledSlot={null}
19+
requestedAvailability={[
20+
{
21+
start: "2026-06-22T13:00:00.000Z",
22+
end: "2026-06-22T15:00:00.000Z",
23+
},
24+
{
25+
start: "2026-06-24T21:00:00.000Z",
26+
end: "2026-06-24T22:30:00.000Z",
27+
},
28+
]}
29+
notes="I'd prefer mornings if possible. Working on edges and crossovers."
30+
/>
31+
);
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { CoordinatorBookingEmail } from "../lib/email/coordinator-booking";
2+
3+
export default function KidsMinimalEmptyStates() {
4+
return (
5+
<CoordinatorBookingEmail
6+
coordinatorName="Jordan Lee"
7+
serviceTitle="Kids Learn-to-Skate (Private)"
8+
client={{
9+
firstName: "Sam",
10+
lastName: "Nguyen",
11+
email: "sam.nguyen@example.com",
12+
address: null,
13+
gender: null,
14+
dob: null,
15+
phone: null,
16+
hasActiveSubscription: false,
17+
}}
18+
scheduledSlot={null}
19+
child={{
20+
firstName: "Theo",
21+
lastName: "Nguyen",
22+
dob: "2018-11-03",
23+
gender: "male",
24+
allergies: null,
25+
medicalConditions: null,
26+
medications: null,
27+
emergencyContacts: [],
28+
formAnswers: [],
29+
}}
30+
/>
31+
);
32+
}

emails/kids-scheduled.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { CoordinatorBookingEmail } from "../lib/email/coordinator-booking";
2+
3+
export default function KidsScheduled() {
4+
return (
5+
<CoordinatorBookingEmail
6+
coordinatorName="Jordan Lee"
7+
serviceTitle="Kids Learn-to-Skate (Private)"
8+
client={{
9+
firstName: "Alex",
10+
lastName: "Rivera",
11+
email: "alex.rivera@example.com",
12+
address: "1240 Rue Sainte-Catherine, Montréal, QC H3B 1A7",
13+
gender: "prefer_not_to_say",
14+
dob: "1992-09-30",
15+
phone: "+1 438-555-0110",
16+
hasActiveSubscription: true,
17+
}}
18+
scheduledSlot={{
19+
start: "2026-06-23T14:30:00.000Z",
20+
end: "2026-06-23T15:15:00.000Z",
21+
}}
22+
child={{
23+
firstName: "Maya",
24+
lastName: "Thompson",
25+
dob: "2016-04-12",
26+
gender: "female",
27+
allergies: "Peanuts, shellfish",
28+
medicalConditions: "Mild asthma",
29+
medications: "Ventolin inhaler as needed",
30+
emergencyContacts: [
31+
{
32+
fullName: "Sarah Thompson",
33+
relationship: "Mother",
34+
phoneNumber: "+1 514-555-0142",
35+
emailAddress: "sarah.thompson@example.com",
36+
},
37+
{
38+
fullName: "David Thompson",
39+
relationship: "Father",
40+
phoneNumber: "+1 514-555-0188",
41+
emailAddress: "david.thompson@example.com",
42+
},
43+
],
44+
formAnswers: [
45+
{
46+
prompt: "Has your child taken lessons before?",
47+
answer: ["Yes — one season of group lessons"],
48+
},
49+
{
50+
prompt: "Select any equipment you need to rent",
51+
answer: ["Helmet", "Skates (size 3)"],
52+
},
53+
{
54+
prompt: "I agree to the waiver and code of conduct",
55+
answer: ["Agreed"],
56+
},
57+
],
58+
}}
59+
/>
60+
);
61+
}

0 commit comments

Comments
 (0)