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, AvailableDatesResponse, AdminAvailability, AppointmentStats, UserAppointmentStats, JitsiMeetingInfo, ApiError, WeeklyAvailabilityResponse, AvailabilityConfig, CheckDateAvailabilityResponse, AvailabilityOverview, SelectedSlot, } from "@/lib/models/appointments"; function extractErrorMessage(error: ApiError): string { if (error.detail) { return Array.isArray(error.detail) ? error.detail.join(", ") : String(error.detail); } if (error.message) { return Array.isArray(error.message) ? error.message.join(", ") : String(error.message); } if (typeof error === "string") { return error; } return "An error occurred"; } async function parseResponse(response: Response): Promise { const responseText = await response.text(); const contentType = response.headers.get("content-type") || ""; if (!responseText || responseText.trim().length === 0) { if (response.ok) { return null; } throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`); } if (contentType.includes("application/json")) { try { return JSON.parse(responseText); } catch { throw new Error(`Server error (${response.status}): Invalid JSON format`); } } const errorMatch = responseText.match(/]*>([\s\S]*?)<\/pre>/i) || responseText.match(/]*>([\s\S]*?)<\/h1>/i); const errorText = errorMatch?.[1]?.replace(/<[^>]*>/g, '').trim() || ''; throw new Error(`Server error (${response.status}): ${errorText || response.statusText || 'Internal Server Error'}`); } function extractHtmlError(responseText: string): string { const errorMatch = responseText.match(/]*>([\s\S]*?)<\/pre>/i); if (!errorMatch) return ''; const traceback = errorMatch[1].replace(/<[^>]*>/g, ''); const lines = traceback.split('\n').filter(line => line.trim()); for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) { const line = lines[i]; if (line.match(/(Error|Exception|Failed)/i)) { return line.trim().replace(/^(Traceback|File|Error|Exception):\s*/i, ''); } } return lines[lines.length - 1]?.trim() || ''; } function validateAndCleanSlots(slots: any[]): SelectedSlot[] { return slots .filter(slot => { if (!slot || typeof slot !== 'object') return false; const dayNum = Number(slot.day); const timeSlot = String(slot.time_slot || '').toLowerCase().trim(); return !isNaN(dayNum) && dayNum >= 0 && dayNum <= 6 && ['morning', 'afternoon', 'evening'].includes(timeSlot); }) .map(slot => ({ day: Number(slot.day), time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening", })); } function normalizeAvailabilitySchedule(schedule: any): Record { if (typeof schedule === 'string') { try { schedule = JSON.parse(schedule); } catch { return {}; } } const numberToTimeSlot: Record = { 0: 'morning', 1: 'afternoon', 2: 'evening', }; const result: Record = {}; Object.keys(schedule || {}).forEach(day => { const slots = schedule[day]; if (Array.isArray(slots) && slots.length > 0) { result[day] = typeof slots[0] === 'number' ? slots.map((num: number) => numberToTimeSlot[num]).filter(Boolean) as string[] : slots.filter((s: string) => ['morning', 'afternoon', 'evening'].includes(s)); } }); return result; } export async function createAppointment(input: CreateAppointmentInput): Promise { const tokens = getStoredTokens(); if (!tokens.access) { throw new Error("Authentication required. Please log in to book an appointment."); } if (!input.first_name || !input.last_name || !input.email) { throw new Error("First name, last name, and email are required"); } if (!input.selected_slots || input.selected_slots.length === 0) { throw new Error("At least one time slot must be selected"); } const validSlots = validateAndCleanSlots(input.selected_slots); 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)."); } // Explicitly exclude legacy fields - API doesn't need preferred_dates or preferred_time_slots // We only use selected_slots format const truncate = (str: string, max: number) => String(str || '').trim().substring(0, max); const selectedSlotsForPayload = validSlots.map(slot => ({ day: slot.day, time_slot: slot.time_slot, })); // Build payload with ONLY the fields the API requires/accepts // DO NOT include preferred_dates or preferred_time_slots - the API doesn't need them const payload: { first_name: string; last_name: string; email: string; selected_slots: Array<{ day: number; time_slot: string }>; phone?: string; reason?: string; } = { first_name: truncate(input.first_name, 100), last_name: truncate(input.last_name, 100), email: truncate(input.email, 100).toLowerCase(), selected_slots: selectedSlotsForPayload, }; // Only add optional fields if they exist if (input.phone && input.phone.length > 0) { payload.phone = truncate(input.phone, 100); } if (input.reason && input.reason.length > 0) { payload.reason = truncate(input.reason, 100); } const requestBody = JSON.stringify(payload); const response = await fetch(API_ENDPOINTS.meetings.createAppointment, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${tokens.access}`, }, body: requestBody, }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } // Build clean response - explicitly exclude preferred_dates and preferred_time_slots // Backend may return these legacy fields, but we only use selected_slots format (per API spec) const rawResponse: any = data.appointment || data.data || data; // Build appointment object from scratch with ONLY the fields we want // Explicitly DO NOT include preferred_dates, preferred_time_slots, or their display variants const appointmentResponse: any = { id: data.appointment_id || rawResponse.id || '', first_name: rawResponse.first_name || input.first_name.trim(), last_name: rawResponse.last_name || input.last_name.trim(), email: rawResponse.email || input.email.trim().toLowerCase(), phone: rawResponse.phone || input.phone?.trim(), reason: rawResponse.reason || input.reason?.trim(), // Use selected_slots from our original input (preserve the format we sent - per API spec) selected_slots: validSlots, status: rawResponse.status || "pending_review", created_at: rawResponse.created_at || new Date().toISOString(), updated_at: rawResponse.updated_at || new Date().toISOString(), // Include other useful fields from response ...(rawResponse.jitsi_meet_url && { jitsi_meet_url: rawResponse.jitsi_meet_url }), ...(rawResponse.jitsi_room_id && { jitsi_room_id: rawResponse.jitsi_room_id }), ...(rawResponse.matching_availability && { matching_availability: rawResponse.matching_availability }), ...(rawResponse.are_preferences_available !== undefined && { are_preferences_available: rawResponse.are_preferences_available }), ...(rawResponse.available_slots_info && { available_slots_info: rawResponse.available_slots_info }), // Explicitly EXCLUDED: preferred_dates, preferred_time_slots, preferred_dates_display, preferred_time_slots_display }; // Explicitly delete preferred_dates and preferred_time_slots from response object // These are backend legacy fields - we only use selected_slots format if ('preferred_dates' in appointmentResponse) { delete appointmentResponse.preferred_dates; } if ('preferred_time_slots' in appointmentResponse) { delete appointmentResponse.preferred_time_slots; } if ('preferred_dates_display' in appointmentResponse) { delete appointmentResponse.preferred_dates_display; } if ('preferred_time_slots_display' in appointmentResponse) { delete appointmentResponse.preferred_time_slots_display; } return appointmentResponse; } export async function getAvailableDates(): Promise { try { const response = await fetch(API_ENDPOINTS.meetings.availableDates, { method: "GET", headers: { "Content-Type": "application/json" }, }); if (!response.ok) { return { dates: [] }; } const data = await parseResponse(response); return Array.isArray(data) ? { dates: data } : data; } catch { return { dates: [] }; } } export async function getWeeklyAvailability(): Promise { const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, { method: "GET", headers: { "Content-Type": "application/json" }, }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return Array.isArray(data) ? data : data; } export async function getAvailabilityConfig(): Promise { const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, { method: "GET", headers: { "Content-Type": "application/json" }, }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data; } export async function checkDateAvailability(date: string): Promise { const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ date }), }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data; } export async function getAvailabilityOverview(): Promise { const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, { method: "GET", headers: { "Content-Type": "application/json" }, }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data; } export async function listAppointments(email?: string): Promise { 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 data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } 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; if (data?.id || data?.first_name) return [data]; return []; } export async function getUserAppointments(): Promise { 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 parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } 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 []; } export async function getAppointmentDetail(id: string): Promise { 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 = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return (data as AppointmentResponse).appointment || data; } export async function scheduleAppointment(id: string, input: ScheduleAppointmentInput): Promise { 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), }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data.appointment || data; } export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise { 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 = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return (data as AppointmentResponse).appointment || data; } export async function getPublicAvailability(): Promise { try { const weeklyAvailability = await getWeeklyAvailability(); const weekArray = Array.isArray(weeklyAvailability) ? weeklyAvailability : (weeklyAvailability as any).week || []; if (!weekArray || weekArray.length === 0) { return null; } const availabilitySchedule: Record = {}; const availableDays: number[] = []; const availableDaysDisplay: string[] = []; weekArray.forEach((day: any) => { if (day.is_available && 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 { return null; } } export async function getAdminAvailability(): Promise { 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 = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } if (data.availability_schedule) { const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule); 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: Array.isArray(data.availability_schedule_display) ? 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; } let availableDays: number[] = []; if (typeof data.available_days === 'string') { try { availableDays = JSON.parse(data.available_days); } catch { 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; } export async function updateAdminAvailability(input: UpdateAvailabilityInput): Promise { const tokens = getStoredTokens(); if (!tokens.access) { throw new Error("Authentication required."); } if (!input.availability_schedule) { throw new Error("availability_schedule is required"); } const cleanedSchedule: Record = {}; Object.keys(input.availability_schedule).forEach(key => { 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) { 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); if (validSlots.length > 0) { cleanedSchedule[key.toString()] = validSlots; } } }); if (Object.keys(cleanedSchedule).length === 0) { throw new Error("At least one day with valid time slots must be provided"); } const sortedSchedule: Record = {}; Object.keys(cleanedSchedule) .sort((a, b) => parseInt(a) - parseInt(b)) .forEach(key => { sortedSchedule[key] = cleanedSchedule[key]; }); let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${tokens.access}`, }, body: JSON.stringify({ availability_schedule: sortedSchedule }), }); 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({ availability_schedule: sortedSchedule }), }); } const responseText = await response.text(); const contentType = response.headers.get("content-type") || ""; if (!responseText || responseText.trim().length === 0) { if (response.ok) { return await getAdminAvailability(); } throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`); } let data: any; if (contentType.includes("application/json")) { try { data = JSON.parse(responseText); } catch { throw new Error(`Server error (${response.status}): Invalid JSON format`); } } else { const htmlError = extractHtmlError(responseText); throw new Error(`Server error (${response.status}): ${htmlError || response.statusText || 'Internal Server Error'}`); } if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } if (data?.availability_schedule) { const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule); 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: Array.isArray(data.availability_schedule_display) ? 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.ok && (!data || Object.keys(data).length === 0)) { return await getAdminAvailability(); } let availableDays: number[] = []; if (typeof data.available_days === 'string') { try { availableDays = JSON.parse(data.available_days); } catch { 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; } export async function getAppointmentStats(): Promise { 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 = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data; } export async function getUserAppointmentStats(): Promise { const tokens = getStoredTokens(); if (!tokens.access) { throw new Error("Authentication required."); } const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, { method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${tokens.access}`, }, }); const data = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data; } export async function getJitsiMeetingInfo(id: string): Promise { 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 = await parseResponse(response); if (!response.ok) { throw new Error(extractErrorMessage(data as unknown as ApiError)); } return data; }