Skip to content

Commit bedd72e

Browse files
committed
feat: add real-time toast notifications for ride events (draft)
1 parent b985dd2 commit bedd72e

11 files changed

Lines changed: 318 additions & 59 deletions

File tree

‎.env.example‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@ JUNO_BASE_URL=your-juno-url
2121
JUNO_EMAIL_SENDER_EMAIL=bitsgood85@gmail.com
2222
JUNO_EMAIL_SENDER_NAME=Able Alliance
2323

24-
# WebSocket server for real-time chat
24+
# WebSocket server for real-time chat and toast notifications
2525
NEXT_PUBLIC_WS_URL=http://localhost:4000
26+
# Server-to-server URL for the Next.js app to reach the websocket server
27+
# (e.g. http://websocket:4000 when running in Docker)
28+
WEBSOCKET_INTERNAL_URL=http://localhost:4000
29+
# Shared secret authorizing the Next.js app to push notifications via the
30+
# websocket server's internal /notify endpoint. Must match the value the
31+
# websocket server is started with.
32+
WEBSOCKET_INTERNAL_SECRET=dev-internal-secret-change-in-production
2633

2734
NEXT_PUBLIC_MAPBOX_TOKEN="put token here"
2835

‎docker-compose.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ services:
1616
SUPERADMIN_LASTNAME: "Admin"
1717
WATCHPACK_POLLING: "true"
1818
NEXT_PUBLIC_WS_URL: "http://localhost:4000"
19+
WEBSOCKET_INTERNAL_URL: "http://websocket:4000"
20+
WEBSOCKET_INTERNAL_SECRET: "dev-internal-secret-change-in-production"
1921
develop:
2022
watch:
2123
- action: rebuild
@@ -93,6 +95,7 @@ services:
9395
environment:
9496
- MONGODB_URI=mongodb://mongo:27017/able-alliance?replicaSet=rs0
9597
- NEXTAUTH_SECRET=dev-secret-change-in-production
98+
- WEBSOCKET_INTERNAL_SECRET=dev-internal-secret-change-in-production
9699
networks:
97100
- able-alliance-net
98101

‎package-lock.json‎

Lines changed: 10 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
@@ -41,6 +41,7 @@
4141
"next-auth": "^5.0.0-beta.30",
4242
"react": "19.2.3",
4343
"react-dom": "19.2.3",
44+
"react-toast": "^1.0.3",
4445
"react-use": "^17.6.0",
4546
"socket.io": "^4.8.3",
4647
"socket.io-client": "^4.8.3",

‎src/app/providers.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"use client";
22

33
import { SessionProvider } from "next-auth/react";
4+
import NotificationProvider from "@/components/NotificationProvider/NotificationProvider";
45

56
export default function Providers({ children }: { children: React.ReactNode }) {
67
return (
78
<SessionProvider refetchOnWindowFocus={false} refetchInterval={5 * 60}>
8-
{children}
9+
<NotificationProvider>{children}</NotificationProvider>
910
</SessionProvider>
1011
);
1112
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import { useSession } from "next-auth/react";
5+
import { io, type Socket } from "socket.io-client";
6+
import { ToastContainer, toast } from "react-toast";
7+
8+
type ToastNotification = {
9+
type: "success" | "error" | "info" | "warn";
10+
message: string;
11+
};
12+
13+
export default function NotificationProvider({
14+
children,
15+
}: {
16+
children: React.ReactNode;
17+
}) {
18+
const { data: session } = useSession();
19+
const socketRef = useRef<Socket | null>(null);
20+
21+
useEffect(() => {
22+
const accessToken = session?.user?.accessToken;
23+
const wsUrl = process.env.NEXT_PUBLIC_WS_URL;
24+
if (!accessToken || !wsUrl || socketRef.current) return;
25+
26+
const socket = io(`${wsUrl}/notifications`, {
27+
auth: { token: accessToken },
28+
transports: ["websocket", "polling"],
29+
reconnection: true,
30+
reconnectionDelay: 1000,
31+
reconnectionDelayMax: 30000,
32+
reconnectionAttempts: 20,
33+
});
34+
socketRef.current = socket;
35+
36+
socket.on("notification", ({ type, message }: ToastNotification) => {
37+
toast[type](message);
38+
});
39+
40+
return () => {
41+
socket.disconnect();
42+
socketRef.current = null;
43+
};
44+
}, [session?.user?.accessToken]);
45+
46+
return (
47+
<>
48+
<ToastContainer position="top-right" />
49+
{children}
50+
</>
51+
);
52+
}

‎src/server/db/actions/RouteAction.ts‎

Lines changed: 125 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ import {
1818
formatEstTime,
1919
} from "@/utils/dateEst";
2020
import { EmailNotifications } from "@/server/email/EmailAction";
21+
import { emitToast } from "@/server/notifications/emitToast";
22+
23+
// Runs an email send alongside the toast emit rather than before it, so a
24+
// slow/failing email provider can't delay real-time toast delivery.
25+
async function sendEmailSafely(label: string, send: () => Promise<void>) {
26+
try {
27+
await send();
28+
} catch (error) {
29+
console.error(`Failed to send ${label} email`, error);
30+
}
31+
}
32+
33+
// Fires email + toast without blocking the caller. Both already swallow
34+
// their own errors (sendEmailSafely, emitToast) — this must never make the
35+
// route-status API response wait on a slow/broken notification provider.
36+
function dispatchNotifications(tasks: Promise<void>[]) {
37+
Promise.all(tasks).catch((error) => {
38+
console.error("Notification dispatch failed", error);
39+
});
40+
}
2141

2242
export async function createRoute(data: CreateRouteInput) {
2343
await connectMongoDB();
@@ -163,14 +183,22 @@ export async function completeRoute(routeId: string) {
163183
}
164184
).settings?.notifications?.rideCompleted
165185
) {
166-
await EmailNotifications.rideCompleted(
167-
studentUser.email,
168-
studentUser.preferredName ??
169-
`${studentUser.firstName} ${studentUser.lastName}`,
170-
{
171-
rideId: route._id.toString(),
172-
},
173-
);
186+
dispatchNotifications([
187+
sendEmailSafely("rideCompleted", () =>
188+
EmailNotifications.rideCompleted(
189+
studentUser.email,
190+
studentUser.preferredName ??
191+
`${studentUser.firstName} ${studentUser.lastName}`,
192+
{
193+
rideId: route._id.toString(),
194+
},
195+
),
196+
),
197+
emitToast(studentUser._id.toString(), {
198+
type: "success",
199+
message: "Your ride has been completed. Thank you for riding with us!",
200+
}),
201+
]);
174202
}
175203

176204
await archiveChatlogForRoute(routeId);
@@ -199,12 +227,23 @@ export async function cancelRoute(routeId: string, status?: string) {
199227
}
200228
).settings?.notifications?.rideCancelled
201229
) {
202-
await EmailNotifications.rideCancelled(
203-
studentUser.email,
204-
studentUser.preferredName ??
205-
`${studentUser.firstName} ${studentUser.lastName}`,
206-
{ rideId: route._id.toString(), reason: `Ride status: ${route.status}` },
207-
);
230+
dispatchNotifications([
231+
sendEmailSafely("rideCancelled", () =>
232+
EmailNotifications.rideCancelled(
233+
studentUser.email,
234+
studentUser.preferredName ??
235+
`${studentUser.firstName} ${studentUser.lastName}`,
236+
{
237+
rideId: route._id.toString(),
238+
reason: `Ride status: ${route.status}`,
239+
},
240+
),
241+
),
242+
emitToast(studentUser._id.toString(), {
243+
type: "error",
244+
message: "Your ride has been cancelled.",
245+
}),
246+
]);
208247
}
209248

210249
if (route.driver?._id) {
@@ -217,15 +256,23 @@ export async function cancelRoute(routeId: string, status?: string) {
217256
}
218257
).settings?.notifications?.rideCancelled
219258
) {
220-
await EmailNotifications.rideCancelled(
221-
driverUser.email,
222-
driverUser.preferredName ??
223-
`${driverUser.firstName} ${driverUser.lastName}`,
224-
{
225-
rideId: route._id.toString(),
226-
reason: `Ride status: ${route.status}`,
227-
},
228-
);
259+
dispatchNotifications([
260+
sendEmailSafely("rideCancelled", () =>
261+
EmailNotifications.rideCancelled(
262+
driverUser.email,
263+
driverUser.preferredName ??
264+
`${driverUser.firstName} ${driverUser.lastName}`,
265+
{
266+
rideId: route._id.toString(),
267+
reason: `Ride status: ${route.status}`,
268+
},
269+
),
270+
),
271+
emitToast(driverUser._id.toString(), {
272+
type: "error",
273+
message: "Your ride has been cancelled.",
274+
}),
275+
]);
229276
}
230277
}
231278

@@ -252,18 +299,26 @@ export async function startRoute(routeId: string) {
252299
}
253300
).settings?.notifications?.driverEnRoute
254301
) {
255-
await EmailNotifications.driverEnRoute(
256-
studentUser.email,
257-
studentUser.preferredName ??
258-
`${studentUser.firstName} ${studentUser.lastName}`,
259-
{
260-
name: route.driver
261-
? `${route.driver.firstName} ${route.driver.lastName}`
262-
: "Driver",
263-
eta: formatEstTime(route.scheduledPickupTime),
264-
vehicle: route.vehicle?.licensePlate ?? "Assigned vehicle",
265-
},
266-
);
302+
dispatchNotifications([
303+
sendEmailSafely("driverEnRoute", () =>
304+
EmailNotifications.driverEnRoute(
305+
studentUser.email,
306+
studentUser.preferredName ??
307+
`${studentUser.firstName} ${studentUser.lastName}`,
308+
{
309+
name: route.driver
310+
? `${route.driver.firstName} ${route.driver.lastName}`
311+
: "Driver",
312+
eta: formatEstTime(route.scheduledPickupTime),
313+
vehicle: route.vehicle?.licensePlate ?? "Assigned vehicle",
314+
},
315+
),
316+
),
317+
emitToast(studentUser._id.toString(), {
318+
type: "info",
319+
message: "Your driver is on the way!",
320+
}),
321+
]);
267322
}
268323

269324
return route.toObject();
@@ -377,32 +432,48 @@ export async function scheduleRoute(
377432
}
378433
).settings?.notifications?.driverAssigned
379434
) {
380-
await EmailNotifications.driverAssigned(
381-
studentUser.email,
382-
studentUser.preferredName ??
383-
`${studentUser.firstName} ${studentUser.lastName}`,
384-
{
385-
name: `${driver.firstName} ${driver.lastName}`,
386-
vehicle: vehicle.licensePlate,
387-
},
388-
);
435+
dispatchNotifications([
436+
sendEmailSafely("driverAssigned", () =>
437+
EmailNotifications.driverAssigned(
438+
studentUser.email,
439+
studentUser.preferredName ??
440+
`${studentUser.firstName} ${studentUser.lastName}`,
441+
{
442+
name: `${driver.firstName} ${driver.lastName}`,
443+
vehicle: vehicle.licensePlate,
444+
},
445+
),
446+
),
447+
emitToast(studentUser._id.toString(), {
448+
type: "success",
449+
message: `Your driver ${driver.firstName} ${driver.lastName} has been assigned.`,
450+
}),
451+
]);
389452
}
390453

391454
if (
392455
driver &&
393456
(driver as { settings?: { notifications?: { rideAssigned?: boolean } } })
394457
.settings?.notifications?.rideAssigned
395458
) {
396-
await EmailNotifications.rideAssigned(
397-
driver.email,
398-
driver.preferredName ?? `${driver.firstName} ${driver.lastName}`,
399-
{
400-
rideId: route._id.toString(),
401-
pickup: route.pickupLocation.toString(),
402-
dropoff: route.dropoffLocation.toString(),
403-
time: `${formatEstDate(route.scheduledPickupTime)} ${formatEstTime(route.scheduledPickupTime)}`,
404-
},
405-
);
459+
dispatchNotifications([
460+
sendEmailSafely("rideAssigned", () =>
461+
EmailNotifications.rideAssigned(
462+
driver.email,
463+
driver.preferredName ?? `${driver.firstName} ${driver.lastName}`,
464+
{
465+
rideId: route._id.toString(),
466+
pickup: route.pickupLocation.toString(),
467+
dropoff: route.dropoffLocation.toString(),
468+
time: `${formatEstDate(route.scheduledPickupTime)} ${formatEstTime(route.scheduledPickupTime)}`,
469+
},
470+
),
471+
),
472+
emitToast(driver._id.toString(), {
473+
type: "info",
474+
message: "You have been assigned a new ride.",
475+
}),
476+
]);
406477
}
407478

408479
// Upsert (not create) so this is safe even if a chat record already

0 commit comments

Comments
 (0)