website/lib/actions/appointments.ts

1204 lines
39 KiB
TypeScript

import { API_ENDPOINTS } from "@/lib/api_urls";
import { getStoredTokens } from "./auth";
import type {
CreateAppointmentInput,
ScheduleAppointmentInput,
RejectAppointmentInput,
UpdateAvailabilityInput,
} from "@/lib/schema/appointments";
import type {
Appointment,
AppointmentResponse,
AppointmentsListResponse,
AvailableDatesResponse,
AdminAvailability,
AppointmentStats,
UserAppointmentStats,
JitsiMeetingInfo,
ApiError,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
SelectedSlot,
} from "@/lib/models/appointments";
// Helper function to extract error message from API response
function extractErrorMessage(error: ApiError): string {
if (error.detail) {
if (Array.isArray(error.detail)) {
return error.detail.join(", ");
}
return String(error.detail);
}
if (error.message) {
if (Array.isArray(error.message)) {
return error.message.join(", ");
}
return String(error.message);
}
if (typeof error === "string") {
return error;
}
return "An error occurred while creating the appointment";
}
// Create appointment
export async function createAppointment(
input: CreateAppointmentInput
): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required. Please log in to book an appointment.");
}
// Validate required fields
if (!input.first_name || !input.last_name || !input.email) {
throw new Error("First name, last name, and email are required");
}
// New API format: use selected_slots
if (!input.selected_slots || input.selected_slots.length === 0) {
throw new Error("At least one time slot must be selected");
}
// Validate and clean selected_slots to ensure all have day and time_slot
// This filters out any invalid slots and ensures proper format
const validSlots: SelectedSlot[] = input.selected_slots
.filter((slot, index) => {
// Check if slot exists and is an object
if (!slot || typeof slot !== 'object') {
return false;
}
// Check if both day and time_slot properties exist
if (typeof slot.day === 'undefined' || typeof slot.time_slot === 'undefined') {
return false;
}
// Validate day is a number between 0-6
const dayNum = Number(slot.day);
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return false;
}
// Validate time_slot is a valid string (normalize to lowercase)
const timeSlot = String(slot.time_slot).toLowerCase().trim();
if (!['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return false;
}
return true;
})
.map(slot => ({
day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening",
}));
if (validSlots.length === 0) {
throw new Error("At least one valid time slot must be selected. Each slot must have both 'day' (0-6) and 'time_slot' (morning, afternoon, or evening).");
}
// Limit field lengths to prevent database errors (100 char limit for all string fields)
// Truncate all string fields BEFORE trimming to handle edge cases
const firstName = input.first_name ? String(input.first_name).trim().substring(0, 100) : '';
const lastName = input.last_name ? String(input.last_name).trim().substring(0, 100) : '';
const email = input.email ? String(input.email).trim().toLowerCase().substring(0, 100) : '';
const phone = input.phone ? String(input.phone).trim().substring(0, 100) : undefined;
const reason = input.reason ? String(input.reason).trim().substring(0, 100) : undefined;
// Build payload with only the fields the API expects - no extra fields
const payload: {
first_name: string;
last_name: string;
email: string;
selected_slots: Array<{ day: number; time_slot: string }>;
phone?: string;
reason?: string;
} = {
first_name: firstName,
last_name: lastName,
email: email,
selected_slots: validSlots.map(slot => ({
day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim(),
})),
};
// Only add optional fields if they have values (and are within length limits)
if (phone && phone.length > 0 && phone.length <= 100) {
payload.phone = phone;
}
if (reason && reason.length > 0 && reason.length <= 100) {
payload.reason = reason;
}
// Final validation: ensure all string fields in payload are exactly 100 chars or less
// This is a safety check to prevent any encoding or serialization issues
const finalPayload = {
first_name: payload.first_name.substring(0, 100),
last_name: payload.last_name.substring(0, 100),
email: payload.email.substring(0, 100),
selected_slots: payload.selected_slots,
...(payload.phone && { phone: payload.phone.substring(0, 100) }),
...(payload.reason && { reason: payload.reason.substring(0, 100) }),
};
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(finalPayload),
});
// Read response text first (can only be read once)
const responseText = await response.text();
// Check content type before parsing
const contentType = response.headers.get("content-type");
let data: any;
if (contentType && contentType.includes("application/json")) {
try {
if (!responseText) {
throw new Error(`Server returned empty response (${response.status})`);
}
data = JSON.parse(responseText);
} catch (e) {
throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
}
} else {
// Response is not JSON (likely HTML error page)
// Try to extract error message from HTML if possible
let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
// Try to find error details in HTML
// Use [\s\S] instead of . with s flag for better compatibility
const errorMatch = responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
responseText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ||
responseText.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (errorMatch && errorMatch[1]) {
const htmlError = errorMatch[1].replace(/<[^>]*>/g, '').trim();
if (htmlError) {
errorMessage += `. ${htmlError}`;
}
}
throw new Error(errorMessage);
}
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
// Handle API response format: { appointment_id, message }
// According to API docs, response includes appointment_id and message
if (data.appointment_id) {
// Construct a minimal Appointment object from the response
// We'll use the input data plus the appointment_id from response
const appointment: Appointment = {
id: data.appointment_id,
first_name: input.first_name.trim(),
last_name: input.last_name.trim(),
email: input.email.trim().toLowerCase(),
phone: input.phone?.trim(),
reason: input.reason?.trim(),
selected_slots: validSlots,
status: "pending_review",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return appointment;
}
// Handle different response formats for backward compatibility
if (data.appointment) {
return data.appointment;
}
if ((data as any).data) {
return (data as any).data;
}
// If appointment is returned directly
return data as unknown as Appointment;
}
// Get available dates (optional endpoint - may fail if admin hasn't set availability)
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
try {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
// Handle different response formats
const contentType = response.headers.get("content-type");
let data: any;
if (contentType && contentType.includes("application/json")) {
const responseText = await response.text();
if (!responseText) {
throw new Error(`Server returned empty response (${response.status})`);
}
try {
data = JSON.parse(responseText);
} catch (parseError) {
throw new Error(`Invalid response format (${response.status})`);
}
} else {
throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response'}`);
}
if (!response.ok) {
// Return empty response instead of throwing - this endpoint is optional
return {
dates: [],
};
}
// If API returns array directly, wrap it in response object
if (Array.isArray(data)) {
return {
dates: data,
};
}
return data as AvailableDatesResponse;
} catch (error) {
// Return empty response - don't break the app
return {
dates: [],
};
}
}
// Get weekly availability (Public)
export async function getWeeklyAvailability(): Promise<WeeklyAvailabilityResponse> {
const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data: any = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
// Handle different response formats - API might return array directly or wrapped
if (Array.isArray(data)) {
return data;
}
// If wrapped in an object, return as is (our interface supports it)
return data;
}
// Get availability configuration (Public)
export async function getAvailabilityConfig(): Promise<AvailabilityConfig> {
const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data: AvailabilityConfig = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
return data;
}
// Check date availability (Public)
export async function checkDateAvailability(date: string): Promise<CheckDateAvailabilityResponse> {
const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ date }),
});
const data: CheckDateAvailabilityResponse = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
return data;
}
// Get availability overview (Public)
export async function getAvailabilityOverview(): Promise<AvailabilityOverview> {
const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data: AvailabilityOverview = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
return data;
}
// List appointments (Admin sees all, users see their own)
export async function listAppointments(email?: string): Promise<Appointment[]> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const url = email
? `${API_ENDPOINTS.meetings.listAppointments}?email=${encodeURIComponent(email)}`
: API_ENDPOINTS.meetings.listAppointments;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const responseText = await response.text();
if (!response.ok) {
let errorData: any;
try {
errorData = JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
}
const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
throw new Error(errorMessage);
}
// Parse JSON response
let data: any;
try {
if (!responseText || responseText.trim().length === 0) {
return [];
}
data = JSON.parse(responseText);
} catch (error) {
throw new Error(`Failed to parse response: Invalid JSON format`);
}
// Handle different response formats
// API returns array directly: [{ id, first_name, ... }, ...]
if (Array.isArray(data)) {
return data;
}
// Handle wrapped responses (if any)
if (data && typeof data === 'object') {
if (data.appointments && Array.isArray(data.appointments)) {
return data.appointments;
}
if (data.results && Array.isArray(data.results)) {
return data.results;
}
// If data is an object but not an array and doesn't have appointments/results, return empty
// This shouldn't happen but handle gracefully
if (data.id || data.first_name) {
// Single appointment object, wrap in array
return [data];
}
}
return [];
}
// Get user appointments
export async function getUserAppointments(): Promise<Appointment[]> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.userAppointments, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
// Handle different response formats
// API might return array directly or wrapped in an object
if (Array.isArray(data)) {
return data;
}
if (data.appointments && Array.isArray(data.appointments)) {
return data.appointments;
}
if (data.results && Array.isArray(data.results)) {
return data.results;
}
return [];
}
// Get appointment detail
export async function getAppointmentDetail(id: string): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data: AppointmentResponse = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
if (data.appointment) {
return data.appointment;
}
return data as unknown as Appointment;
}
// Schedule appointment (Admin only)
export async function scheduleAppointment(
id: string,
input: ScheduleAppointmentInput
): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/schedule/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(input),
});
let data: any;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
try {
const text = await response.text();
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
} else {
const text = await response.text();
data = text || {};
}
if (!response.ok) {
// Try to extract detailed error information
let errorMessage = `Failed to schedule appointment (${response.status})`;
if (data && Object.keys(data).length > 0) {
// Check for common error formats
if (data.detail) {
errorMessage = Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
} else if (data.message) {
errorMessage = Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
} else if (data.error) {
errorMessage = Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
} else if (typeof data === "string") {
errorMessage = data;
} else {
// Check for field-specific errors
const fieldErrors: string[] = [];
Object.keys(data).forEach((key) => {
if (key !== "detail" && key !== "message" && key !== "error") {
const fieldError = data[key];
if (Array.isArray(fieldError)) {
fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
} else if (typeof fieldError === "string") {
fieldErrors.push(`${key}: ${fieldError}`);
}
}
});
if (fieldErrors.length > 0) {
errorMessage = fieldErrors.join(". ");
} else {
// If we have data but can't parse it, show the status
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
}
} else {
// No data in response
errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
}
throw new Error(errorMessage);
}
if (data.appointment) {
return data.appointment;
}
return data as unknown as Appointment;
}
// Reject appointment (Admin only)
export async function rejectAppointment(
id: string,
input: RejectAppointmentInput
): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reject/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(input),
});
const data: AppointmentResponse = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
if (data.appointment) {
return data.appointment;
}
return data as unknown as Appointment;
}
// Get admin availability (public version - uses weekly availability endpoint instead)
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
try {
// Use weekly availability endpoint which is public
const weeklyAvailability = await getWeeklyAvailability();
// Normalize to array format
const weekArray = Array.isArray(weeklyAvailability)
? weeklyAvailability
: (weeklyAvailability as any).week || [];
if (!weekArray || weekArray.length === 0) {
return null;
}
// Convert weekly availability to AdminAvailability format
const availabilitySchedule: Record<string, string[]> = {};
const availableDays: number[] = [];
const availableDaysDisplay: string[] = [];
weekArray.forEach((day: any) => {
if (day.is_available && day.available_slots && day.available_slots.length > 0) {
availabilitySchedule[day.day.toString()] = day.available_slots;
availableDays.push(day.day);
availableDaysDisplay.push(day.day_name);
}
});
return {
available_days: availableDays,
available_days_display: availableDaysDisplay,
availability_schedule: availabilitySchedule,
all_available_slots: weekArray
.filter((d: any) => d.is_available)
.flatMap((d: any) => d.available_slots.map((slot: string) => ({ day: d.day, time_slot: slot as "morning" | "afternoon" | "evening" }))),
} as AdminAvailability;
} catch (error) {
return null;
}
}
// Get admin availability
export async function getAdminAvailability(): Promise<AdminAvailability> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data: any = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
// Handle new format with availability_schedule
// API returns availability_schedule, which may be a JSON string or object
// Time slots are strings: "morning", "afternoon", "evening"
if (data.availability_schedule) {
let availabilitySchedule: Record<string, string[]>;
// Map numeric indices to string names (in case API returns numeric indices)
const numberToTimeSlot: Record<number, string> = {
0: 'morning',
1: 'afternoon',
2: 'evening',
};
// Parse if it's a string, otherwise use as-is
let rawSchedule: Record<string, number[] | string[]>;
if (typeof data.availability_schedule === 'string') {
try {
rawSchedule = JSON.parse(data.availability_schedule);
} catch (parseError) {
rawSchedule = {};
}
} else {
rawSchedule = data.availability_schedule;
}
// Convert to string format, handling both numeric indices and string values
availabilitySchedule = {};
Object.keys(rawSchedule).forEach(day => {
const slots = rawSchedule[day];
if (Array.isArray(slots) && slots.length > 0) {
// Check if slots are numbers (indices) or already strings
if (typeof slots[0] === 'number') {
// Convert numeric indices to string names
availabilitySchedule[day] = (slots as number[])
.map((num: number) => numberToTimeSlot[num])
.filter((slot: string | undefined) => slot !== undefined) as string[];
} else {
// Already strings, validate and use as-is
availabilitySchedule[day] = (slots as string[]).filter(slot =>
['morning', 'afternoon', 'evening'].includes(slot)
);
}
}
});
const availableDays = Object.keys(availabilitySchedule).map(Number);
// Generate available_days_display if not provided
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
return {
available_days: availableDays,
available_days_display: data.availability_schedule_display ? [data.availability_schedule_display] : availableDaysDisplay,
availability_schedule: availabilitySchedule,
availability_schedule_display: data.availability_schedule_display,
all_available_slots: data.all_available_slots || [],
} as AdminAvailability;
}
// Handle legacy format
let availableDays: number[] = [];
if (typeof data.available_days === 'string') {
try {
availableDays = JSON.parse(data.available_days);
} catch {
// If parsing fails, try splitting by comma
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
}
} else if (Array.isArray(data.available_days)) {
availableDays = data.available_days;
}
return {
available_days: availableDays,
available_days_display: data.available_days_display || [],
} as AdminAvailability;
}
// Update admin availability
export async function updateAdminAvailability(
input: UpdateAvailabilityInput
): Promise<AdminAvailability> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
// Prepare payload using new format (availability_schedule)
// API expects availability_schedule as an object with string keys (day numbers) and string arrays (time slot names)
// Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
const payload: any = {};
if (input.availability_schedule) {
// Validate and clean the schedule object
// API expects: { "0": ["morning", "evening"], "1": ["afternoon"], ... }
// Time slots are strings: "morning", "afternoon", "evening"
const cleanedSchedule: Record<string, string[]> = {};
Object.keys(input.availability_schedule).forEach(key => {
// Ensure key is a valid day (0-6)
const dayNum = parseInt(key);
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return;
}
const slots = input.availability_schedule[key];
if (Array.isArray(slots) && slots.length > 0) {
// Filter to only valid time slot strings and remove duplicates
const validSlots = slots
.filter((slot: string) =>
typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot)
)
.filter((slot: string, index: number, self: string[]) =>
self.indexOf(slot) === index
); // Remove duplicates
if (validSlots.length > 0) {
// Ensure day key is a string (as per API spec)
cleanedSchedule[key.toString()] = validSlots;
}
}
});
if (Object.keys(cleanedSchedule).length === 0) {
throw new Error("At least one day with valid time slots must be provided");
}
// Sort the schedule keys for consistency
const sortedSchedule: Record<string, string[]> = {};
Object.keys(cleanedSchedule)
.sort((a, b) => parseInt(a) - parseInt(b))
.forEach(key => {
sortedSchedule[key] = cleanedSchedule[key];
});
// IMPORTANT: API expects availability_schedule as an object (not stringified)
// Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
payload.availability_schedule = sortedSchedule;
} else if (input.available_days) {
// Legacy format: available_days
payload.available_days = Array.isArray(input.available_days)
? input.available_days.map(day => Number(day))
: input.available_days;
} else {
throw new Error("Either availability_schedule or available_days must be provided");
}
// Try PUT first, fallback to PATCH if needed
// The payload object will be JSON stringified, including availability_schedule as an object
let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
// If PUT fails with 500, try PATCH (some APIs prefer PATCH for updates)
if (!response.ok && response.status === 500) {
response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
}
// Read response text first (can only be read once)
const responseText = await response.text();
let data: any;
// Get content type
const contentType = response.headers.get("content-type") || "";
// Handle empty response
if (!responseText || responseText.trim().length === 0) {
// If successful status but empty response, refetch the availability
if (response.ok) {
return await getAdminAvailability();
}
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response from server'}`);
}
// Try to parse as JSON
if (contentType.includes("application/json")) {
try {
data = JSON.parse(responseText);
} catch (parseError) {
throw new Error(`Server error (${response.status}): Invalid JSON response format`);
}
} else {
// Response is not JSON - try to extract useful information
// Try to extract error message from HTML if it's an HTML error page
let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
let actualError = '';
let errorType = '';
let fullTraceback = '';
if (responseText) {
// Extract Django error details from HTML
const titleMatch = responseText.match(/<title[^>]*>(.*?)<\/title>/i);
const h1Match = responseText.match(/<h1[^>]*>(.*?)<\/h1>/i);
// Try to find the actual error traceback in <pre> tags (Django debug pages)
const tracebackMatch = responseText.match(/<pre[^>]*class="[^"]*traceback[^"]*"[^>]*>([\s\S]*?)<\/pre>/i) ||
responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
// Extract the actual error type and message
const errorTypeMatch = responseText.match(/<h2[^>]*>(.*?)<\/h2>/i);
if (tracebackMatch && tracebackMatch[1]) {
// Extract the full traceback and find the actual error
const tracebackText = tracebackMatch[1].replace(/<[^>]*>/g, ''); // Remove HTML tags
fullTraceback = tracebackText;
const tracebackLines = tracebackText.split('\n').filter(line => line.trim());
// Look for the actual error message - Django errors usually appear at the end
// First, try to find error patterns in the entire traceback
const errorPatterns = [
// Database column errors
/column\s+[\w.]+\.(\w+)\s+(does not exist|already exists|is missing)/i,
// Programming errors
/(ProgrammingError|OperationalError|IntegrityError|DatabaseError|ValueError|TypeError|AttributeError|KeyError):\s*(.+?)(?:\n|$)/i,
// Generic error patterns
/^(\w+Error):\s*(.+)$/i,
// Error messages without type
/^(.+Error[:\s]+.+)$/i,
];
// Search from the end backwards (errors are usually at the end)
for (let i = tracebackLines.length - 1; i >= 0; i--) {
const line = tracebackLines[i];
// Check each pattern
for (const pattern of errorPatterns) {
const match = line.match(pattern);
if (match) {
// For database column errors, capture the full message
if (pattern.source.includes('column')) {
actualError = match[0];
errorType = 'DatabaseError';
} else if (match[1] && match[2]) {
errorType = match[1];
actualError = match[2].trim();
} else {
actualError = match[0];
}
break;
}
}
if (actualError) break;
}
// If no pattern match, look for lines containing "Error" or common error keywords
if (!actualError) {
for (let i = tracebackLines.length - 1; i >= Math.max(0, tracebackLines.length - 10); i--) {
const line = tracebackLines[i];
if (line.match(/(Error|Exception|Failed|Invalid|Missing|does not exist|already exists)/i)) {
actualError = line;
break;
}
}
}
// Last resort: get the last line
if (!actualError && tracebackLines.length > 0) {
actualError = tracebackLines[tracebackLines.length - 1];
}
// Clean up the error message
if (actualError) {
actualError = actualError.trim();
// Remove common prefixes
actualError = actualError.replace(/^(Traceback|File|Error|Exception):\s*/i, '');
}
} else if (errorTypeMatch && errorTypeMatch[1]) {
errorType = errorTypeMatch[1].replace(/<[^>]*>/g, '').trim();
actualError = errorType;
if (errorType && errorType.length < 200) {
errorMessage += `. ${errorType}`;
}
} else if (h1Match && h1Match[1]) {
actualError = h1Match[1].replace(/<[^>]*>/g, '').trim();
if (actualError && actualError.length < 200) {
errorMessage += `. ${actualError}`;
}
} else if (titleMatch && titleMatch[1]) {
actualError = titleMatch[1].replace(/<[^>]*>/g, '').trim();
if (actualError && actualError.length < 200) {
errorMessage += `. ${actualError}`;
}
}
}
// Update error message with the extracted error
if (actualError) {
errorMessage = `Server error (${response.status}): ${actualError}`;
}
throw new Error(errorMessage);
}
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
// Build detailed error message
let detailedError = `Server error (${response.status}): `;
if (data && typeof data === 'object') {
if (data.detail) {
detailedError += Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
} else if (data.error) {
detailedError += Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
} else if (data.message) {
detailedError += Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
} else {
detailedError += response.statusText || 'Failed to update availability';
}
} else if (responseText && responseText.length > 0) {
// Try to extract error from HTML response if it's not JSON
detailedError += responseText.substring(0, 200);
} else {
detailedError += response.statusText || 'Failed to update availability';
}
throw new Error(detailedError);
}
// Handle new format with availability_schedule in response
// API returns availability_schedule, which may be a JSON string or object
// Time slots may be strings or numeric indices
if (data && data.availability_schedule) {
let availabilitySchedule: Record<string, string[]>;
// Map numeric indices to string names (in case API returns numeric indices)
const numberToTimeSlot: Record<number, string> = {
0: 'morning',
1: 'afternoon',
2: 'evening',
};
// Parse if it's a string, otherwise use as-is
let rawSchedule: Record<string, number[] | string[]>;
if (typeof data.availability_schedule === 'string') {
try {
rawSchedule = JSON.parse(data.availability_schedule);
} catch (parseError) {
rawSchedule = {};
}
} else if (typeof data.availability_schedule === 'object') {
rawSchedule = data.availability_schedule;
} else {
rawSchedule = {};
}
// Convert to string format, handling both numeric indices and string values
availabilitySchedule = {};
Object.keys(rawSchedule).forEach(day => {
const slots = rawSchedule[day];
if (Array.isArray(slots) && slots.length > 0) {
// Check if slots are numbers (indices) or already strings
if (typeof slots[0] === 'number') {
// Convert numeric indices to string names
availabilitySchedule[day] = (slots as number[])
.map((num: number) => numberToTimeSlot[num])
.filter((slot: string | undefined) => slot !== undefined) as string[];
} else {
// Already strings, validate and use as-is
availabilitySchedule[day] = (slots as string[]).filter(slot =>
['morning', 'afternoon', 'evening'].includes(slot)
);
}
}
});
const availableDays = Object.keys(availabilitySchedule).map(Number);
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
return {
available_days: availableDays,
available_days_display: data.availability_schedule_display ?
(Array.isArray(data.availability_schedule_display) ?
data.availability_schedule_display :
[data.availability_schedule_display]) :
availableDaysDisplay,
availability_schedule: availabilitySchedule,
availability_schedule_display: data.availability_schedule_display,
all_available_slots: data.all_available_slots || [],
} as AdminAvailability;
}
// If response is empty but successful (200), return empty availability
// This might happen if the server doesn't return data on success
if (response.ok && (!data || Object.keys(data).length === 0)) {
// Refetch the availability to get the updated data
return getAdminAvailability();
}
// Handle legacy format
let availableDays: number[] = [];
if (typeof data.available_days === 'string') {
try {
availableDays = JSON.parse(data.available_days);
} catch {
// If parsing fails, try splitting by comma
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
}
} else if (Array.isArray(data.available_days)) {
availableDays = data.available_days;
}
return {
available_days: availableDays,
available_days_display: data.available_days_display || [],
} as AdminAvailability;
}
// Get appointment stats (Admin only)
export async function getAppointmentStats(): Promise<AppointmentStats> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}stats/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data: AppointmentStats = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
return data;
}
// Get user appointment stats
export async function getUserAppointmentStats(email: string): Promise<UserAppointmentStats> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
if (!email) {
throw new Error("Email is required to fetch user appointment stats.");
}
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify({ email }),
});
const responseText = await response.text();
if (!response.ok) {
let errorData: any;
try {
errorData = JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
}
const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
throw new Error(errorMessage);
}
let data: UserAppointmentStats;
try {
if (!responseText || responseText.trim().length === 0) {
throw new Error("Empty response from server");
}
data = JSON.parse(responseText);
} catch (error) {
throw new Error(`Failed to parse response: Invalid JSON format`);
}
return data;
}
// Get Jitsi meeting info
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/jitsi-meeting/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data: JitsiMeetingInfo = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
return data;
}