From 3c048f65e1e89575d3cd1ed6714a3303a233ba74 Mon Sep 17 00:00:00 2001 From: Alaa Date: Thu, 26 Feb 2026 09:39:34 +0100 Subject: [PATCH 1/5] fix: support button in video call cell --- client/src/components/table/LessonRow.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/components/table/LessonRow.tsx b/client/src/components/table/LessonRow.tsx index 0637fea..59af917 100644 --- a/client/src/components/table/LessonRow.tsx +++ b/client/src/components/table/LessonRow.tsx @@ -17,7 +17,7 @@ export type LessonRowData = { date?: string; time?: string; status?: AppointmentStatus; - videoCall?: string; + videoCall?: ReactNode; onStatusChange?: (status: AppointmentStatus) => void; onDelete?: () => void; canDelete?: boolean; @@ -108,6 +108,7 @@ const LessonRow = ({ disabled={isPastLesson} /> ) : column.key === "videoCall" && + typeof data[column.key] === "string" && data[column.key] && data[column.key] !== "N/A" ? ( Start call + ) : column.key === "videoCall" ? ( + + {data[column.key] as ReactNode} + ) : column.key === "date" ? ( Date: Thu, 26 Feb 2026 11:48:13 +0100 Subject: [PATCH 2/5] Add start call logic to teachersAppointments page --- .../TeacherAppointments.tsx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx index fada836..b19657a 100644 --- a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx +++ b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx @@ -8,11 +8,15 @@ import { useDeleteAppointmentMutation } from "../../../features/appointments/mut import { AppointmentStatus } from "../../../types/appointments.types"; import { LessonRowData } from "../../../components/table/LessonRow"; import { useModalStore } from "../../../store/modals.store"; +import { Button } from "../../../components/ui/button/Button"; +import { startCall } from "../../../api/video/video.api"; +import { useAuthSessionStore } from "../../../store/authSession.store"; export const TeacherAppointments = () => { const [page, setPage] = useState(1); const [selectedIds, setSelectedIds] = useState([]); const limit = 10; + const user = useAuthSessionStore((state) => state.user); const { open: openModal } = useModalStore(); @@ -58,6 +62,27 @@ export const TeacherAppointments = () => { }); }; + const handleStartCall = async (studentId: string, appointmentId?: string) => { + if (!user?.id) return; + + try { + const call = await startCall({ + teacherId: user.id, + studentId, + appointmentId: appointmentId ?? undefined, + streamCallId: `call_${crypto.randomUUID()}`, + }); + + const callUrl = `/call/${call.id}?streamCallId=${encodeURIComponent( + call.streamCallId, + )}&streamCallType=${encodeURIComponent(call.streamCallType)}`; + + window.open(callUrl, "_blank", "noopener,noreferrer"); + } catch (error) { + console.error("Failed to start call", error); + } + }; + const isPastAppointment = (date: string, time: string): boolean => { if (!date || !time) { return false; @@ -132,7 +157,18 @@ export const TeacherAppointments = () => { price: appointment.price, date: appointment.date, time: appointment.time, - videoCall: appointment.videoCall || "N/A", + videoCall: ( + + ), status: appointment.status, isPast: isPast, onStatusChange: !isPast From 7dda2ae82d7fe926c828d75d4b0fdc2d477de844 Mon Sep 17 00:00:00 2001 From: Alaa Date: Thu, 26 Feb 2026 15:31:52 +0100 Subject: [PATCH 3/5] Implement join-call-url link --- client/src/components/table/LessonRow.tsx | 2 +- .../ClientsAppointments.tsx | 9 ++- server/src/db/schemes/videoCallSchema.ts | 4 + .../commandRepositories/videoCall.command.ts | 14 +++- .../appointment/appointment.service.ts | 74 ++++++++++++++++++- .../src/services/video/videoCall.service.ts | 46 +++++++++--- 6 files changed, 131 insertions(+), 18 deletions(-) diff --git a/client/src/components/table/LessonRow.tsx b/client/src/components/table/LessonRow.tsx index 59af917..3cdca44 100644 --- a/client/src/components/table/LessonRow.tsx +++ b/client/src/components/table/LessonRow.tsx @@ -117,7 +117,7 @@ const LessonRow = ({ rel="noopener noreferrer" className={`underline ${isPastLesson ? "text-gray-500" : "text-[#B9B9B9]"} hover:text-[#7186FF]`} > - Start call + {(data.linkText as string) || "Join"} ) : column.key === "videoCall" ? ( { .filter((appointment) => appointment.date && appointment.time) .map((appointment) => { const isPast = isPastAppointment(appointment.date, appointment.time); + + //only allow internal StudyBridge call routes; block stale/external links (e.g. old Google Meet URLs). + const isInternalCallLink = + typeof appointment.videoCall === "string" && + appointment.videoCall.startsWith("/call/"); + return { id: appointment.id, checked: false, @@ -170,7 +176,8 @@ export const ClientsAppointments = () => { price: appointment.price, date: appointment.date, time: appointment.time, - videoCall: appointment.videoCall || "N/A", + videoCall: isInternalCallLink ? appointment.videoCall : "N/A", + linkText: isTeacher ? "Start call" : "Join", status: appointment.status, isPast: isPast, onStatusChange: diff --git a/server/src/db/schemes/videoCallSchema.ts b/server/src/db/schemes/videoCallSchema.ts index c033814..c2f026f 100644 --- a/server/src/db/schemes/videoCallSchema.ts +++ b/server/src/db/schemes/videoCallSchema.ts @@ -34,6 +34,10 @@ export const VideoCallSchema = new mongoose.Schema( // helps the app quickly find active incoming calls for a student. VideoCallSchema.index({ studentId: 1, status: 1, expiresAt: 1 }); +// helps lookup and sort latest call candidates by appointment and status. +VideoCallSchema.index({ appointmentId: 1, status: 1, createdAt: -1 }); +// helps filtering of non-expired calls by appointment and status +VideoCallSchema.index({ appointmentId: 1, status: 1, expiresAt: 1 }); export const VideoCallModel = mongoose.model>( "videoCall", diff --git a/server/src/repositories/commandRepositories/videoCall.command.ts b/server/src/repositories/commandRepositories/videoCall.command.ts index bd117db..03583bd 100644 --- a/server/src/repositories/commandRepositories/videoCall.command.ts +++ b/server/src/repositories/commandRepositories/videoCall.command.ts @@ -35,16 +35,26 @@ export class VideoCallCommand { } } - async acceptCallById(callId: string): Promise { + async acceptCallById( + callId: string, + nextExpiresAt: Date, + studentId: string, + ): Promise { const now = new Date(); try { const updated = await VideoCallModel.findOneAndUpdate( - { id: callId }, + { + id: callId, + studentId, + status: { $in: ["ringing", "missed"] }, + }, { $set: { status: "accepted", startedAt: now, + // Long-lived join window so student can rejoin after popup timeout. + expiresAt: nextExpiresAt, updatedAt: now, }, }, diff --git a/server/src/services/appointment/appointment.service.ts b/server/src/services/appointment/appointment.service.ts index 5bee726..83de04f 100644 --- a/server/src/services/appointment/appointment.service.ts +++ b/server/src/services/appointment/appointment.service.ts @@ -12,6 +12,7 @@ import { StudentModel } from "../../db/schemes/studentSchema.js"; import { TeacherModel } from "../../db/schemes/teacherSchema.js"; import { ConversationCommand } from "../../repositories/commandRepositories/conversation.command.js"; import { logError, logWarning } from "../../utils/logging.js"; +import { VideoCallModel } from "../../db/schemes/videoCallSchema.js"; @injectable() export class AppointmentService { @@ -102,8 +103,12 @@ export class AppointmentService { const appointmentsWithNames = await this.appointmentQuery.populateNames( result.appointments, ); + + const appointmentsWithVideoLinks = + await this.attachVideoCallLinksToAppointments(appointmentsWithNames); + return { - appointments: appointmentsWithNames.map((apt) => + appointments: appointmentsWithVideoLinks.map((apt) => this.formatAppointmentResponse(apt), ), total: result.total, @@ -124,8 +129,12 @@ export class AppointmentService { const appointmentsWithNames = await this.appointmentQuery.populateNames( result.appointments, ); + + const appointmentsWithVideoLinks = + await this.attachVideoCallLinksToAppointments(appointmentsWithNames); + return { - appointments: appointmentsWithNames.map((apt) => + appointments: appointmentsWithVideoLinks.map((apt) => this.formatAppointmentResponse(apt), ), total: result.total, @@ -218,6 +227,67 @@ export class AppointmentService { return appointments.map((apt) => this.formatAppointmentResponse(apt)); } + // Create the call link that students can click to open this exact video call. + private buildVideoCallJoinUrl( + callId: string, + streamCallId: string, + streamCallType?: string, + ): string { + const type = streamCallType || "default"; + + return `/call/${callId}?streamCallId=${encodeURIComponent( + streamCallId, + )}&streamCallType=${encodeURIComponent(type)}`; + } + + private async attachVideoCallLinksToAppointments< + T extends { id: string; videoCall?: string }, + >(appointments: T[]): Promise { + const now = new Date(); + const MISSED_JOIN_GRACE_MS = 3 * 60 * 60 * 1000; // 3 hours + const appointmentIds = appointments.map((a) => a.id).filter(Boolean); + + if (!appointmentIds.length) return appointments; + + const calls = await VideoCallModel.find({ + appointmentId: { $in: appointmentIds }, + $or: [ + // include ringing even if popup expiry passed; accept flow can still convert it. + { status: "ringing" }, + { status: "accepted", expiresAt: { $gt: now } }, + { + status: "missed", + createdAt: { $gt: new Date(now.getTime() - MISSED_JOIN_GRACE_MS) }, + }, + ], + }) + .sort({ createdAt: -1 }) + .select("id appointmentId streamCallId streamCallType") + .lean(); + + const latestByAppointmentId = new Map(); + for (const call of calls) { + if (!call.appointmentId) continue; + if (!latestByAppointmentId.has(call.appointmentId)) { + latestByAppointmentId.set(call.appointmentId, call); + } + } + + return appointments.map((appointment) => { + const call = latestByAppointmentId.get(appointment.id); + if (!call) return appointment; + + return { + ...appointment, + videoCall: this.buildVideoCallJoinUrl( + call.id, + call.streamCallId, + call.streamCallType, + ), + }; + }); + } + private formatAppointmentResponse(appointment: unknown) { const apt = appointment as { id: string; diff --git a/server/src/services/video/videoCall.service.ts b/server/src/services/video/videoCall.service.ts index f306445..ffdd876 100644 --- a/server/src/services/video/videoCall.service.ts +++ b/server/src/services/video/videoCall.service.ts @@ -14,6 +14,8 @@ import { } from "../../types/video/video.types.js"; import { randomUUID } from "node:crypto"; +const ACCEPTED_CALL_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours + @injectable() export class VideoCallService { constructor( @@ -33,7 +35,7 @@ export class VideoCallService { if (authRole !== "teacher") throw new HttpError(403, "Only teachers can start calls"); - //// Safety check: a teacher can only start a call using their own logged-in ID. + //a teacher can only start a call using if (authUserId !== teacherId) throw new HttpError(403, "You can only start calls as yourself"); @@ -140,28 +142,48 @@ export class VideoCallService { }): Promise { const now = new Date(); - if (authRole !== "student") + // only students can accept. + if (authRole !== "student") { throw new HttpError(403, "Students only can accept the call"); + } - //check if call exists + // check call existence and ownership. const call = await this.videoCallQuery.getVideoById(callId); if (!call) throw new HttpError(404, "Call not found"); - if (call.expiresAt <= now) { + if (call.studentId !== authUserId) { + throw new HttpError(403, "Only the assigned student can accept the call"); + } + + // expired ringing => mark missed for popup tracking only. + if (call.status === "ringing" && call.expiresAt <= now) { await this.videoCallCommand.markExpiredCallAsMissed(callId); - throw new HttpError(409, "Call has expired"); } - if (call.studentId !== authUserId) - throw new HttpError(403, "Only students can accept the call"); + const acceptedCall = await this.videoCallCommand.acceptCallById( + callId, + new Date(Date.now() + ACCEPTED_CALL_TTL_MS), + authUserId, + ); - if (call.status !== "ringing") { - throw new HttpError(409, "Call is no longer ringing"); - } + // if update did not happen, inspect latest status. + if (!acceptedCall) { + const latest = await this.videoCallQuery.getVideoById(callId); + if (!latest) throw new HttpError(404, "Call not found"); + + if (latest.status === "ended" || latest.status === "declined") { + throw new HttpError(409, "Call is no longer joinable"); + } - const acceptedCall = await this.videoCallCommand.acceptCallById(callId); + // if already accepted, allow client to proceed. + if (latest.status === "accepted") { + return latest; + } + + throw new HttpError(409, "Call is no longer joinable"); + } - return acceptedCall ?? null; + return acceptedCall; } async declineCall({ From e91b60a620fc3d90e866070ce31c107df308d27a Mon Sep 17 00:00:00 2001 From: Alaa Date: Thu, 26 Feb 2026 16:27:31 +0100 Subject: [PATCH 4/5] Add confirm modal to start call button --- .../components/confirmDialog/ConfirmDialog.tsx | 10 +++++++++- client/src/components/modalHost/modalHost.tsx | 5 +++-- .../teacherAppointments/TeacherAppointments.tsx | 15 ++++++++++++++- client/src/store/modals.store.ts | 3 +++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/client/src/components/confirmDialog/ConfirmDialog.tsx b/client/src/components/confirmDialog/ConfirmDialog.tsx index e1dd885..e074510 100644 --- a/client/src/components/confirmDialog/ConfirmDialog.tsx +++ b/client/src/components/confirmDialog/ConfirmDialog.tsx @@ -7,6 +7,7 @@ interface ConfirmDialogProps { message: string; confirmText?: string; cancelText?: string; + confirmVariant?: "danger" | "primary"; onConfirm: () => void; onCancel: () => void; } @@ -17,9 +18,15 @@ export const ConfirmDialog: React.FC = ({ message, confirmText = "Confirm", cancelText = "Cancel", + confirmVariant = "danger", onConfirm, onCancel, }) => { + const confirmBtnClass = + confirmVariant === "primary" + ? "px-6 py-3 bg-[#2563EB] hover:bg-[#1D4ED8] text-white rounded-lg text-[16px] font-semibold transition-colors" + : "px-6 py-3 bg-[#DC2626] hover:bg-[#B91C1C] text-white rounded-lg text-[16px] font-semibold transition-colors"; + if (!isOpen) return null; return ( @@ -48,7 +55,8 @@ export const ConfirmDialog: React.FC = ({ diff --git a/client/src/components/modalHost/modalHost.tsx b/client/src/components/modalHost/modalHost.tsx index 572a808..386560d 100644 --- a/client/src/components/modalHost/modalHost.tsx +++ b/client/src/components/modalHost/modalHost.tsx @@ -68,8 +68,9 @@ export const ModalHost = () => { isOpen={opened} title={payload.title} message={payload.message} - confirmText="Delete" - cancelText="Cancel" + confirmText={payload.confirmText ?? "Confirm"} + cancelText={payload.cancelText ?? "Cancel"} + confirmVariant={payload.confirmVariant ?? "danger"} onConfirm={() => { payload.onConfirm(); close(); diff --git a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx index b19657a..742db1c 100644 --- a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx +++ b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx @@ -62,6 +62,19 @@ export const TeacherAppointments = () => { }); }; + const confirmStartCall = (studentId: string, appointmentId?: string) => { + openModal("confirmDelete", { + title: "Start Video Call", + message: "Do you want to start this call now?", + confirmText: "Confirm", + cancelText: "Cancel", + confirmVariant: "primary", + onConfirm: () => { + void handleStartCall(studentId, appointmentId); + }, + }); + }; + const handleStartCall = async (studentId: string, appointmentId?: string) => { if (!user?.id) return; @@ -163,7 +176,7 @@ export const TeacherAppointments = () => { variant="link" className="text-inherit underline font-normal min-h-0 min-w-0 rounded-none" onClick={() => - handleStartCall(appointment.studentId, appointment.id) + confirmStartCall(appointment.studentId, appointment.id) } > Start call! diff --git a/client/src/store/modals.store.ts b/client/src/store/modals.store.ts index 2a04c9a..f23abcb 100644 --- a/client/src/store/modals.store.ts +++ b/client/src/store/modals.store.ts @@ -26,6 +26,9 @@ type ModalPayload = { title: string; message: string; onConfirm: () => void; + confirmText?: string; + cancelText?: string; + confirmVariant?: "danger" | "primary"; }; alert?: { title: string; From d323ce8d1222165894c5927c965c32426f97fee3 Mon Sep 17 00:00:00 2001 From: Alaa Date: Thu, 26 Feb 2026 17:38:58 +0100 Subject: [PATCH 5/5] Fix confirm button in the modal --- .../teacherAppointments/TeacherAppointments.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx index 742db1c..43bff3e 100644 --- a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx +++ b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx @@ -78,6 +78,15 @@ export const TeacherAppointments = () => { const handleStartCall = async (studentId: string, appointmentId?: string) => { if (!user?.id) return; + const callWindow = window.open("about:blank", "_blank"); + if (!callWindow) { + openModal("alert", { + title: "Popup blocked", + message: "Please allow popups for this site, then try again.", + }); + return; + } + try { const call = await startCall({ teacherId: user.id, @@ -90,8 +99,9 @@ export const TeacherAppointments = () => { call.streamCallId, )}&streamCallType=${encodeURIComponent(call.streamCallType)}`; - window.open(callUrl, "_blank", "noopener,noreferrer"); + callWindow.location.href = callUrl; } catch (error) { + callWindow.close(); console.error("Failed to start call", error); } };