1204 lines
39 KiB
TypeScript
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;
|
|
}
|
|
|