Skip to content

Commit f6ba6ef

Browse files
authored
Merge pull request #100 from GTBitsOfGood/cindy/email-notifications
Email Notifications
2 parents 7110ec5 + d29cca7 commit f6ba6ef

12 files changed

Lines changed: 641 additions & 22 deletions

File tree

‎netlify/functions/daily-email.mts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { Config, Handler } from "@netlify/functions";
2+
import connectMongoDB from "../../src/server/db/mongodb";
3+
import RouteModel from "../../src/server/db/models/RouteModel";
4+
import { UserModel } from "../../src/server/db/models/UserModel";
5+
import { EmailNotifications } from "../../src/server/email/EmailAction";
6+
import {
7+
endOfEstDay,
8+
formatEstDate,
9+
formatEstTime,
10+
startOfEstDay,
11+
} from "../../src/utils/dateEst";
12+
13+
export const config: Config = {
14+
// 7 AM EST
15+
schedule: "0 12 * * *",
16+
};
17+
18+
type UserForSummary = {
19+
_id: { toString(): string };
20+
firstName: string;
21+
lastName: string;
22+
preferredName?: string;
23+
email: string;
24+
type: "Student" | "Driver";
25+
};
26+
27+
type RouteForSummary = {
28+
_id: { toString(): string };
29+
scheduledPickupTime: Date;
30+
status: string;
31+
student: { _id: { toString(): string } };
32+
driver?: { _id: { toString(): string } };
33+
pickupLocation: { name: string };
34+
dropoffLocation: { name: string };
35+
};
36+
37+
const handler: Handler = async () => {
38+
await connectMongoDB();
39+
40+
const day = new Date();
41+
const [dayStart, dayEnd] = [startOfEstDay(day), endOfEstDay(day)];
42+
const optedInUsers = (await UserModel.find({
43+
type: { $in: ["Student", "Driver"] },
44+
"settings.notifications.dailySummary": true,
45+
})
46+
.select("firstName lastName preferredName email type")
47+
.lean()) as unknown as UserForSummary[];
48+
49+
if (optedInUsers.length === 0) {
50+
return { statusCode: 200, body: "No users opted in to daily summaries." };
51+
}
52+
53+
const userIds = optedInUsers.map((user) => user._id);
54+
const routes = (await RouteModel.find({
55+
scheduledPickupTime: { $gte: dayStart, $lte: dayEnd },
56+
$or: [
57+
{ "student._id": { $in: userIds } },
58+
{ "driver._id": { $in: userIds } },
59+
],
60+
})
61+
.populate([
62+
{ path: "pickupLocation", select: "name" },
63+
{ path: "dropoffLocation", select: "name" },
64+
])
65+
.lean()) as unknown as RouteForSummary[];
66+
67+
const usersById = new Map(
68+
optedInUsers.map((user) => [user._id.toString(), user]),
69+
);
70+
const ridesByUserId = new Map<string, RouteForSummary[]>();
71+
72+
for (const route of routes) {
73+
const recipientIds = [
74+
route.student._id.toString(),
75+
route.driver?._id.toString(),
76+
].filter((id): id is string => Boolean(id));
77+
for (const userId of recipientIds) {
78+
if (usersById.has(userId)) {
79+
const rides = ridesByUserId.get(userId) ?? [];
80+
rides.push(route);
81+
ridesByUserId.set(userId, rides);
82+
}
83+
}
84+
}
85+
86+
const deliveries = [...ridesByUserId.entries()].map(
87+
async ([userId, rides]) => {
88+
const user = usersById.get(userId);
89+
if (!user) return;
90+
91+
await EmailNotifications.dailySummary(
92+
user.email,
93+
user.preferredName ?? `${user.firstName} ${user.lastName}`,
94+
rides.map((ride) => ({
95+
rideId: ride._id.toString(),
96+
date: formatEstDate(ride.scheduledPickupTime),
97+
time: formatEstTime(ride.scheduledPickupTime),
98+
pickup: ride.pickupLocation.name,
99+
dropoff: ride.dropoffLocation.name,
100+
status: ride.status,
101+
})),
102+
);
103+
},
104+
);
105+
106+
const results = await Promise.allSettled(deliveries);
107+
const failures = results.filter((result) => result.status === "rejected");
108+
if (failures.length > 0) {
109+
console.error(`Failed to send ${failures.length} daily ride summaries.`);
110+
return {
111+
statusCode: 500,
112+
body: `Sent ${results.length - failures.length} summaries; ${failures.length} failed.`,
113+
};
114+
}
115+
116+
return {
117+
statusCode: 200,
118+
body: `Sent ${results.length} daily ride summaries.`,
119+
};
120+
};
121+
122+
export default handler;

‎package-lock.json‎

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"test:e2e:report": "playwright show-report"
2222
},
2323
"dependencies": {
24+
"@netlify/functions": "^6.0.0",
2425
"@phosphor-icons/react": "^2.1.10",
2526
"@radix-ui/react-checkbox": "^1.3.3",
2627
"@radix-ui/react-icons": "^1.3.2",

‎src/app/api/users/[id]/route.ts‎

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
updateStudentInfo,
88
updateDriverShifts,
99
updatePreferredName,
10+
updateNotificationSettings,
1011
} from "@/server/db/actions/UserAction";
1112
import { findInvalidAccommodations } from "@/server/db/actions/AccommodationAction";
1213
import { HTTP_STATUS_CODE } from "@/utils/consts";
@@ -94,6 +95,26 @@ const driverShiftsPatchSchema = z
9495
})
9596
.strict();
9697

98+
const notificationSettingsPatchSchema = z
99+
.object({
100+
settings: z
101+
.object({
102+
notifications: z
103+
.object({
104+
dailySummary: z.boolean().optional(),
105+
driverAssigned: z.boolean().optional(),
106+
driverEnRoute: z.boolean().optional(),
107+
rideCancelled: z.boolean().optional(),
108+
rideAssigned: z.boolean().optional(),
109+
rideCompleted: z.boolean().optional(),
110+
})
111+
.optional(),
112+
})
113+
.passthrough()
114+
.optional(),
115+
})
116+
.passthrough();
117+
97118
export async function PATCH(
98119
req: NextRequest,
99120
{ params }: { params: Promise<{ id: string }> },
@@ -149,9 +170,33 @@ export async function PATCH(
149170
}
150171

151172
try {
173+
const parsedNotif = notificationSettingsPatchSchema.safeParse(body);
174+
let notificationUpdated;
175+
if (parsedNotif.success && parsedNotif.data.settings?.notifications) {
176+
notificationUpdated = await updateNotificationSettings(id, {
177+
notifications: parsedNotif.data.settings.notifications,
178+
});
179+
180+
if (!notificationUpdated) {
181+
return NextResponse.json(
182+
{ error: "Update failed" },
183+
{ status: HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR },
184+
);
185+
}
186+
}
187+
152188
let updated;
189+
const bodyWithoutSettings =
190+
typeof body === "object" && body !== null
191+
? Object.fromEntries(
192+
Object.entries(body as Record<string, unknown>).filter(
193+
([key]) => key !== "settings",
194+
),
195+
)
196+
: {};
197+
153198
if (user.type === "Student") {
154-
const parsed = studentInfoPatchSchema.safeParse(body);
199+
const parsed = studentInfoPatchSchema.safeParse(bodyWithoutSettings);
155200
if (!parsed.success) {
156201
return NextResponse.json(parsed.error.format(), {
157202
status: HTTP_STATUS_CODE.BAD_REQUEST,
@@ -200,24 +245,26 @@ export async function PATCH(
200245
}
201246
} else if (user.type === "Driver") {
202247
// For drivers, only admins can update shifts
203-
if (!isAdmin) {
248+
if (!isAdmin && bodyWithoutSettings.shifts !== undefined) {
204249
return NextResponse.json(
205250
{ error: "Only admins can update driver shifts" },
206251
{ status: HTTP_STATUS_CODE.FORBIDDEN },
207252
);
208253
}
209-
const parsed = driverShiftsPatchSchema.safeParse(body);
254+
const parsed = driverShiftsPatchSchema.safeParse(bodyWithoutSettings);
210255
if (!parsed.success) {
211256
return NextResponse.json(parsed.error.format(), {
212257
status: HTTP_STATUS_CODE.BAD_REQUEST,
213258
});
214259
}
215-
updated = await updateDriverShifts(id, parsed.data.shifts ?? []);
216-
if (!updated) {
217-
return NextResponse.json(
218-
{ error: "User not found or update failed" },
219-
{ status: HTTP_STATUS_CODE.NOT_FOUND },
220-
);
260+
if (parsed.data.shifts !== undefined) {
261+
updated = await updateDriverShifts(id, parsed.data.shifts ?? []);
262+
if (!updated) {
263+
return NextResponse.json(
264+
{ error: "User not found or update failed" },
265+
{ status: HTTP_STATUS_CODE.NOT_FOUND },
266+
);
267+
}
221268
}
222269
} else {
223270
return NextResponse.json(
@@ -226,9 +273,18 @@ export async function PATCH(
226273
);
227274
}
228275

229-
const userObj = updated as Record<string, unknown> & {
230-
_id: { toString(): string };
231-
};
276+
const finalUser = (updated ?? notificationUpdated) as
277+
| (Record<string, unknown> & { _id: { toString(): string } })
278+
| null;
279+
280+
if (!finalUser) {
281+
return NextResponse.json(
282+
{ error: "User update failed" },
283+
{ status: HTTP_STATUS_CODE.BAD_REQUEST },
284+
);
285+
}
286+
287+
const userObj = finalUser;
232288
return NextResponse.json(
233289
{ ...userObj, _id: userObj._id.toString() },
234290
{ status: HTTP_STATUS_CODE.OK },

0 commit comments

Comments
 (0)