Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion client/src/components/confirmDialog/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ConfirmDialogProps {
message: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: "danger" | "primary";
onConfirm: () => void;
onCancel: () => void;
}
Expand All @@ -17,9 +18,15 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
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 (
Expand Down Expand Up @@ -48,7 +55,8 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</button>
<button
onClick={onConfirm}
className="px-6 py-3 bg-[#DC2626] hover:bg-[#B91C1C] text-white rounded-lg text-[16px] font-semibold transition-colors"
className={confirmBtnClass}
// className="px-6 py-3 bg-[#DC2626] hover:bg-[#B91C1C] text-white rounded-lg text-[16px] font-semibold transition-colors"
>
{confirmText}
</button>
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/modalHost/modalHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions client/src/components/table/LessonRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,6 +108,7 @@ const LessonRow = ({
disabled={isPastLesson}
/>
) : column.key === "videoCall" &&
typeof data[column.key] === "string" &&
data[column.key] &&
data[column.key] !== "N/A" ? (
<a
Expand All @@ -116,8 +117,14 @@ const LessonRow = ({
rel="noopener noreferrer"
className={`underline ${isPastLesson ? "text-gray-500" : "text-[#B9B9B9]"} hover:text-[#7186FF]`}
>
Start call
{(data.linkText as string) || "Join"}
</a>
) : column.key === "videoCall" ? (
<span
className={isPastLesson ? "text-gray-500" : "text-[#B9B9B9]"}
>
{data[column.key] as ReactNode}
</span>
) : column.key === "date" ? (
<span
className={isPastLesson ? "text-red-500" : "text-green-700"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ export const ClientsAppointments = () => {
.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,
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);
const limit = 10;
const user = useAuthSessionStore((state) => state.user);

const { open: openModal } = useModalStore();

Expand Down Expand Up @@ -58,6 +62,50 @@ 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;

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,
studentId,
appointmentId: appointmentId ?? undefined,
streamCallId: `call_${crypto.randomUUID()}`,
});

const callUrl = `/call/${call.id}?streamCallId=${encodeURIComponent(
call.streamCallId,
)}&streamCallType=${encodeURIComponent(call.streamCallType)}`;

callWindow.location.href = callUrl;
} catch (error) {
callWindow.close();
console.error("Failed to start call", error);
}
};

const isPastAppointment = (date: string, time: string): boolean => {
if (!date || !time) {
return false;
Expand Down Expand Up @@ -132,7 +180,18 @@ export const TeacherAppointments = () => {
price: appointment.price,
date: appointment.date,
time: appointment.time,
videoCall: appointment.videoCall || "N/A",
videoCall: (
<Button
as="button"
variant="link"
className="text-inherit underline font-normal min-h-0 min-w-0 rounded-none"
onClick={() =>
confirmStartCall(appointment.studentId, appointment.id)
}
>
Start call!
</Button>
),
status: appointment.status,
isPast: isPast,
onStatusChange: !isPast
Expand Down
3 changes: 3 additions & 0 deletions client/src/store/modals.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type ModalPayload = {
title: string;
message: string;
onConfirm: () => void;
confirmText?: string;
cancelText?: string;
confirmVariant?: "danger" | "primary";
};
alert?: {
title: string;
Expand Down
4 changes: 4 additions & 0 deletions server/src/db/schemes/videoCallSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const VideoCallSchema = new mongoose.Schema<VideoCallDB>(

// 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<WithId<VideoCallDB>>(
"videoCall",
Expand Down
14 changes: 12 additions & 2 deletions server/src/repositories/commandRepositories/videoCall.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,26 @@ export class VideoCallCommand {
}
}

async acceptCallById(callId: string): Promise<VideoCallViewType | null> {
async acceptCallById(
callId: string,
nextExpiresAt: Date,
studentId: string,
): Promise<VideoCallViewType | null> {
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,
},
},
Expand Down
74 changes: 72 additions & 2 deletions server/src/services/appointment/appointment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<T[]> {
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<string, (typeof calls)[number]>();
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;
Expand Down
Loading
Loading