Skip to content

Commit d29cca7

Browse files
authored
Merge branch 'main' into cindy/email-notifications
2 parents e252049 + 7110ec5 commit d29cca7

14 files changed

Lines changed: 376 additions & 241 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ next-env.d.ts
4242
dist
4343
.cursor
4444

45+
# genai
46+
CLAUDE.md
47+
4548
# playwright
4649
/test-results/
4750
/playwright-report/

‎src/app/rides/[id]/page.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ export default function RideDetailPage({
646646
const body = await res.json();
647647
throw new Error(body.error ?? res.statusText);
648648
}
649-
socketRef.current?.emit("endRoute");
649+
socketRef.current?.emit("closeRouteRoom");
650650
setShowCancelModal(false);
651651
router.push("/rides");
652652
} catch (e) {
@@ -673,7 +673,7 @@ export default function RideDetailPage({
673673
}
674674
const updated: RouteData = await res.json();
675675
setRoute(updated);
676-
if (terminal) socketRef.current?.emit("endRoute");
676+
if (terminal) socketRef.current?.emit("closeRouteRoom");
677677
} catch (e) {
678678
setDriverActionError(e instanceof Error ? e.message : errorMsg);
679679
} finally {
@@ -720,7 +720,7 @@ export default function RideDetailPage({
720720
}
721721
const updated: RouteData = await res.json();
722722
setRoute(updated);
723-
socketRef.current?.emit("endRoute");
723+
socketRef.current?.emit("closeRouteRoom");
724724
} catch (e) {
725725
setMissingError(
726726
e instanceof Error ? e.message : "Failed to mark as missing.",

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import RouteModel, { RouteStatus } from "../models/RouteModel";
33
import LocationModel from "../models/LocationModel";
44
import UserModel from "../models/UserModel";
55
import VehicleModel from "../models/VehicleModel";
6+
import Chatlog from "../models/ChatlogModel";
67
import { createRouteSchema, type CreateRouteInput } from "@/utils/types";
78
import {
89
RouteAlreadyExistsException,
@@ -138,6 +139,13 @@ export async function getRoutes(filters?: {
138139
return routes;
139140
}
140141

142+
async function archiveChatlogForRoute(routeId: string) {
143+
await Chatlog.findOneAndUpdate(
144+
{ routeId, status: "active" },
145+
{ $set: { status: "archived", archivedAt: new Date() } },
146+
);
147+
}
148+
141149
export async function completeRoute(routeId: string) {
142150
await connectMongoDB();
143151
const route = await RouteModel.findById(routeId);
@@ -165,6 +173,8 @@ export async function completeRoute(routeId: string) {
165173
);
166174
}
167175

176+
await archiveChatlogForRoute(routeId);
177+
168178
return route.toObject();
169179
}
170180
export async function cancelRoute(routeId: string, status?: string) {
@@ -219,6 +229,8 @@ export async function cancelRoute(routeId: string, status?: string) {
219229
}
220230
}
221231

232+
await archiveChatlogForRoute(routeId);
233+
222234
return route.toObject();
223235
}
224236

@@ -278,6 +290,7 @@ export async function markRouteMissing(routeId: string) {
278290
}
279291
route.status = RouteStatus.Missing;
280292
await route.save();
293+
await archiveChatlogForRoute(routeId);
281294
return route.toObject();
282295
}
283296

@@ -390,6 +403,29 @@ export async function scheduleRoute(
390403
time: `${formatEstDate(route.scheduledPickupTime)} ${formatEstTime(route.scheduledPickupTime)}`,
391404
},
392405
);
406+
// Upsert (not create) so this is safe even if a chat record already
407+
// exists for this route; the unique index on routeId means a genuine
408+
// race between two concurrent schedule calls can still surface as a
409+
// duplicate-key error on the losing side, which is expected, not a bug.
410+
try {
411+
await Chatlog.findOneAndUpdate(
412+
{ routeId: route._id },
413+
{
414+
$setOnInsert: {
415+
routeId: route._id,
416+
student: route.student,
417+
driver: driverEmbed,
418+
time: new Date(),
419+
status: "active",
420+
messages: [],
421+
},
422+
},
423+
{ upsert: true },
424+
);
425+
} catch (error) {
426+
if ((error as { code?: number }).code !== 11000) {
427+
throw error;
428+
}
393429
}
394430

395431
return route.toObject();

‎src/server/db/models/ChatlogModel.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import type { IBaseUser, IStudentUser } from "./UserModel";
44

55
interface IMessage {
66
time: Date;
7-
senderType: "Student" | "Driver";
7+
// Lowercase to match the room UI's comparison against the session's
8+
// userType.toLowerCase() (see src/app/rides/[id]/page.tsx).
9+
senderType: "student" | "driver" | "admin";
810
text: string;
911
}
1012

@@ -13,12 +15,18 @@ export interface IChatlog {
1315
student: IStudentUser & { _id: mongoose.Types.ObjectId };
1416
driver: IBaseUser & { _id: mongoose.Types.ObjectId };
1517
time: Date;
18+
status: "active" | "archived";
19+
archivedAt?: Date;
1620
messages: IMessage[];
1721
}
1822

1923
const MessageSchema = new Schema<IMessage>({
2024
time: { type: Date, required: true },
21-
senderType: { type: String, enum: ["Student", "Driver"], required: true },
25+
senderType: {
26+
type: String,
27+
enum: ["student", "driver", "admin"],
28+
required: true,
29+
},
2230
text: { type: String, required: true },
2331
});
2432

@@ -37,10 +45,17 @@ const ChatlogSchema = new Schema<IChatlog>({
3745
required: true,
3846
},
3947
time: { type: Date, required: true },
48+
status: {
49+
type: String,
50+
enum: ["active", "archived"],
51+
required: true,
52+
default: "active",
53+
},
54+
archivedAt: { type: Date },
4055
messages: [MessageSchema],
4156
});
4257

43-
ChatlogSchema.index({ routeId: 1 });
58+
ChatlogSchema.index({ routeId: 1 }, { unique: true });
4459
ChatlogSchema.index({ time: 1 });
4560

4661
const Chatlog =

‎websocket-server/auth.mjs‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import jwt from "jsonwebtoken";
2+
import { debugLog } from "./utils/logger.mjs";
3+
import { getRouteForAuth } from "./utils/db.mjs";
4+
5+
// Compare dates in America/New_York using Intl (works reliably in Node).
6+
function estDateStr(date) {
7+
const parts = new Intl.DateTimeFormat("en-US", {
8+
timeZone: "America/New_York",
9+
year: "numeric",
10+
month: "2-digit",
11+
day: "2-digit",
12+
}).formatToParts(date);
13+
const get = (type) => parts.find((p) => p.type === type)?.value;
14+
return `${get("year")}-${get("month")}-${get("day")}`;
15+
}
16+
17+
// Socket.IO middleware: verifies the client's JWT, loads the route, and
18+
// authorizes the connecting user (student/driver/super admin, same-day only).
19+
export async function authenticateSocket(socket, next) {
20+
const { routeId, token } = socket.handshake.auth;
21+
try {
22+
debugLog(
23+
`Auth attempt for routeId: ${routeId} and token is ${token ? "present" : "missing"} `,
24+
);
25+
if (!routeId || !token) {
26+
return next(new Error("Route ID or token missing"));
27+
}
28+
29+
let decoded;
30+
try {
31+
const secret = process.env.NEXTAUTH_SECRET;
32+
decoded = jwt.verify(token, secret);
33+
debugLog("JWT decoded successfully:", decoded);
34+
} catch (error) {
35+
console.error("JWT verify failed:", error.message);
36+
return next(new Error("Invalid JWT token"));
37+
}
38+
39+
const userId = decoded.userId;
40+
debugLog("User ID from token:", userId);
41+
42+
const route = await getRouteForAuth(routeId);
43+
if (!route) {
44+
return next(new Error("Route not found"));
45+
}
46+
47+
const routeDateStr = estDateStr(new Date(route.scheduledPickupTime));
48+
const todayStr = estDateStr(new Date());
49+
const isSameDay = routeDateStr === todayStr;
50+
debugLog(
51+
`Date check: route=${routeDateStr}, today=${todayStr}, same=${isSameDay}`,
52+
);
53+
if (!isSameDay) {
54+
return next(new Error("Route is not scheduled for today"));
55+
}
56+
57+
const isStudent = route.student?._id?.toString() === userId;
58+
const isDriver = route.driver?._id?.toString() === userId;
59+
const isSuperAdmin = decoded.type === "SuperAdmin";
60+
61+
if (!isStudent && !isDriver && !isSuperAdmin) {
62+
return next(new Error("User not authorized for this route"));
63+
}
64+
65+
socket.routeId = routeId;
66+
socket.user = userId;
67+
socket.userType = decoded.type;
68+
socket.routeStudent = route.student;
69+
socket.routeDriver = route.driver;
70+
next();
71+
} catch (error) {
72+
console.error(`Auth error for routeId ${routeId}:`, error);
73+
return next(new Error("Authentication failed"));
74+
}
75+
}

‎websocket-server/handlers/chat.mjs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { appendMessage } from "../utils/db.mjs";
2+
import { debugLog } from "../utils/logger.mjs";
3+
4+
export function registerChatHandler(io, socket, room, chatReady) {
5+
socket.on("sendChatMessage", async (text) => {
6+
try {
7+
await chatReady;
8+
9+
if (typeof text !== "string") {
10+
throw new Error("Message text is required");
11+
}
12+
13+
const routeId = socket.routeId;
14+
// Lowercase to match the client's senderType === userType.toLowerCase()
15+
// comparison (src/app/rides/[id]/page.tsx).
16+
const senderType =
17+
socket.routeDriver?._id?.toString() === socket.user
18+
? "driver"
19+
: socket.routeStudent?._id?.toString() === socket.user
20+
? "student"
21+
: "admin";
22+
23+
const message = { senderType, text, time: new Date() };
24+
const saved = await appendMessage(routeId, message);
25+
if (!saved) {
26+
socket.emit("chatError", "This ride's chat has ended");
27+
return;
28+
}
29+
30+
io.to(room).emit("receiveChatMessage", message);
31+
debugLog(`User ${socket.user} sent a message to room ${room}`);
32+
} catch (error) {
33+
console.error(
34+
`sendChatMessage failed for user ${socket.user} in room ${room}:`,
35+
error,
36+
);
37+
socket.emit("chatError", "Invalid message format");
38+
}
39+
});
40+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Purely a socket.io room operation now — creating and archiving the chat
2+
// record lives in RouteAction.ts (Next.js), tied to the route's actual
3+
// status transitions rather than to a client's socket being connected.
4+
export function registerCloseRouteRoomHandler(io, socket, room) {
5+
socket.on("closeRouteRoom", async () => {
6+
try {
7+
const routeId = socket.routeId;
8+
9+
io.to(room).emit("routeClosed");
10+
11+
const sockets = await io.in(routeId).fetchSockets();
12+
for (const s of sockets) {
13+
s.disconnect(true);
14+
}
15+
console.log(`Route ${routeId} closed`);
16+
} catch (error) {
17+
console.error(
18+
`closeRouteRoom failed for user ${socket.user} in room ${room}:`,
19+
error,
20+
);
21+
socket.emit("closeRouteRoomError", "Failed to close route room");
22+
}
23+
});
24+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { debugLog } from "../utils/logger.mjs";
2+
import { getChatHistory } from "../utils/db.mjs";
3+
import { registerChatHandler } from "./chat.mjs";
4+
import { registerLocationHandler } from "./location.mjs";
5+
import { registerCloseRouteRoomHandler } from "./closeRouteRoom.mjs";
6+
7+
export function handleConnection(io, socket) {
8+
console.log("A user connected", socket.id);
9+
10+
const room = socket.routeId;
11+
try {
12+
socket.join(room);
13+
debugLog(`User ${socket.user} joined room ${room}`);
14+
} catch (error) {
15+
console.error(
16+
`Failed to set up connection for user ${socket.user} in room ${room}:`,
17+
error,
18+
);
19+
socket.emit("connectionError", "Failed to join route");
20+
socket.disconnect(true);
21+
return;
22+
}
23+
24+
// Handler is registered immediately (below) so a fast client's events
25+
// can't be dropped while this resolves; the chat handler awaits this
26+
// promise before touching the chat record. The record itself is created
27+
// by RouteAction.ts when the ride is scheduled — this only reads it.
28+
const chatReady = getChatHistory(room)
29+
.then((history) => {
30+
socket.emit("chatHistory", history);
31+
debugLog(
32+
`Sent chat history to user ${socket.user} for room ${room} with ${history.length} messages`,
33+
);
34+
})
35+
.catch((error) => {
36+
console.error(
37+
`Failed to load chat history for user ${socket.user} in room ${room}:`,
38+
error,
39+
);
40+
socket.emit("connectionError", "Failed to join route");
41+
socket.disconnect(true);
42+
throw error;
43+
});
44+
45+
registerChatHandler(io, socket, room, chatReady);
46+
registerLocationHandler(socket, room);
47+
registerCloseRouteRoomHandler(io, socket, room);
48+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { debugLog } from "../utils/logger.mjs";
2+
3+
export function registerLocationHandler(socket, room) {
4+
socket.on("updateLocation", async (location) => {
5+
try {
6+
if (
7+
typeof location.latitude !== "number" ||
8+
typeof location.longitude !== "number"
9+
) {
10+
throw new Error("Coordinates are invalid");
11+
}
12+
socket.to(room).emit("broadcastLocation", location);
13+
debugLog(`User ${socket.user} updated location in room ${room}`);
14+
} catch (error) {
15+
console.error(
16+
`updateLocation failed for user ${socket.user} in room ${room}:`,
17+
error,
18+
);
19+
socket.emit("locationError", "Failed to update location");
20+
}
21+
});
22+
}

‎websocket-server/httpRoutes.mjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function registerHttpRoutes(app) {
2+
app.get("/", (req, res) => {
3+
res.json({ message: "Express server running", ok: true });
4+
});
5+
app.get("/health", (req, res) => {
6+
res.status(200).send("OK");
7+
});
8+
}

0 commit comments

Comments
 (0)