2025-11-23 21:43:13 +00:00
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 ,
2025-11-25 21:25:53 +00:00
UserAppointmentStats ,
2025-11-23 21:43:13 +00:00
JitsiMeetingInfo ,
ApiError ,
2025-11-27 19:18:59 +00:00
WeeklyAvailabilityResponse ,
AvailabilityConfig ,
CheckDateAvailabilityResponse ,
AvailabilityOverview ,
SelectedSlot ,
2025-11-23 21:43:13 +00:00
} 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." ) ;
}
2025-11-26 11:42:31 +00:00
// Validate required fields
if ( ! input . first_name || ! input . last_name || ! input . email ) {
throw new Error ( "First name, last name, and email are required" ) ;
}
2025-11-27 19:18:59 +00:00
// 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" ) ;
2025-11-26 11:42:31 +00:00
}
2025-11-27 19:18:59 +00:00
// 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)." ) ;
2025-11-26 11:42:31 +00:00
}
2025-11-27 19:18:59 +00:00
// 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 ;
2025-11-26 11:42:31 +00:00
2025-11-27 19:18:59 +00:00
// Build payload with only the fields the API expects - no extra fields
2025-11-26 11:42:31 +00:00
const payload : {
first_name : string ;
last_name : string ;
email : string ;
2025-11-27 19:18:59 +00:00
selected_slots : Array < { day : number ; time_slot : string } > ;
2025-11-26 11:42:31 +00:00
phone? : string ;
reason? : string ;
} = {
2025-11-27 19:18:59 +00:00
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 ( ) ,
} ) ) ,
2025-11-26 11:42:31 +00:00
} ;
2025-11-27 19:18:59 +00:00
// Only add optional fields if they have values (and are within length limits)
if ( phone && phone . length > 0 && phone . length <= 100 ) {
payload . phone = phone ;
2025-11-26 11:42:31 +00:00
}
2025-11-27 19:18:59 +00:00
if ( reason && reason . length > 0 && reason . length <= 100 ) {
payload . reason = reason ;
2025-11-26 11:42:31 +00:00
}
2025-11-27 19:18:59 +00:00
// 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 ) } ) ,
} ;
2025-11-26 11:42:31 +00:00
2025-11-23 21:43:13 +00:00
const response = await fetch ( API_ENDPOINTS . meetings . createAppointment , {
method : "POST" ,
headers : {
"Content-Type" : "application/json" ,
Authorization : ` Bearer ${ tokens . access } ` ,
} ,
2025-11-27 19:18:59 +00:00
body : JSON.stringify ( finalPayload ) ,
2025-11-23 21:43:13 +00:00
} ) ;
2025-11-26 11:42:31 +00:00
// 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
2025-11-26 11:44:16 +00:00
// 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 ) ;
2025-11-26 11:42:31 +00:00
if ( errorMatch && errorMatch [ 1 ] ) {
const htmlError = errorMatch [ 1 ] . replace ( /<[^>]*>/g , '' ) . trim ( ) ;
if ( htmlError ) {
errorMessage += ` . ${ htmlError } ` ;
}
}
throw new Error ( errorMessage ) ;
}
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
2025-11-27 19:18:59 +00:00
// 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
2025-11-23 21:43:13 +00:00
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 ;
}
2025-11-27 19:18:59 +00:00
// Get available dates (optional endpoint - may fail if admin hasn't set availability)
2025-11-25 21:04:22 +00:00
export async function getAvailableDates ( ) : Promise < AvailableDatesResponse > {
2025-11-27 19:18:59 +00:00
try {
2025-11-23 21:43:13 +00:00
const response = await fetch ( API_ENDPOINTS . meetings . availableDates , {
method : "GET" ,
headers : {
"Content-Type" : "application/json" ,
} ,
} ) ;
2025-11-27 19:18:59 +00:00
// 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' } ` ) ;
}
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-27 19:18:59 +00:00
// Return empty response instead of throwing - this endpoint is optional
return {
dates : [ ] ,
} ;
2025-11-23 21:43:13 +00:00
}
2025-11-25 21:04:22 +00:00
// If API returns array directly, wrap it in response object
2025-11-23 21:43:13 +00:00
if ( Array . isArray ( data ) ) {
2025-11-25 21:04:22 +00:00
return {
dates : data ,
} ;
2025-11-23 21:43:13 +00:00
}
2025-11-25 21:04:22 +00:00
return data as AvailableDatesResponse ;
2025-11-27 19:18:59 +00:00
} 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 ;
2025-11-23 21:43:13 +00:00
}
// 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 } ` ,
} ,
} ) ;
2025-11-27 19:18:59 +00:00
const responseText = await response . text ( ) ;
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-27 19:18:59 +00:00
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 ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
2025-11-27 19:18:59 +00:00
// 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 ` ) ;
}
2025-11-23 22:28:02 +00:00
// Handle different response formats
2025-11-27 19:18:59 +00:00
// API returns array directly: [{ id, first_name, ... }, ...]
2025-11-23 22:28:02 +00:00
if ( Array . isArray ( data ) ) {
return data ;
}
2025-11-27 19:18:59 +00:00
// Handle wrapped responses (if any)
if ( data && typeof data === 'object' ) {
2025-11-23 22:28:02 +00:00
if ( data . appointments && Array . isArray ( data . appointments ) ) {
return data . appointments ;
}
if ( data . results && Array . isArray ( data . results ) ) {
return data . results ;
2025-11-27 19:18:59 +00:00
}
// 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 ] ;
}
2025-11-23 22:28:02 +00:00
}
return [ ] ;
2025-11-23 21:43:13 +00:00
}
// 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 } ` ,
} ,
} ) ;
2025-11-23 22:28:02 +00:00
const data = await response . json ( ) ;
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
2025-11-23 22:28:02 +00:00
// 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 [ ] ;
2025-11-23 21:43:13 +00:00
}
// 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 ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
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 ) ,
} ) ;
2025-11-26 11:42:31 +00:00
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 || { } ;
}
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-26 11:42:31 +00:00
// 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' } ` ;
}
2025-11-23 21:43:13 +00:00
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 ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
if ( data . appointment ) {
return data . appointment ;
}
return data as unknown as Appointment ;
}
2025-11-27 19:18:59 +00:00
// Get admin availability (public version - uses weekly availability endpoint instead)
2025-11-25 21:04:22 +00:00
export async function getPublicAvailability ( ) : Promise < AdminAvailability | null > {
try {
2025-11-27 19:18:59 +00:00
// 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 ) {
2025-11-25 21:04:22 +00:00
return null ;
}
2025-11-27 19:18:59 +00:00
// Convert weekly availability to AdminAvailability format
const availabilitySchedule : Record < string , string [ ] > = { } ;
const availableDays : number [ ] = [ ] ;
const availableDaysDisplay : string [ ] = [ ] ;
2025-11-25 21:04:22 +00:00
2025-11-27 19:18:59 +00:00
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 ) ;
2025-11-25 21:04:22 +00:00
}
2025-11-27 19:18:59 +00:00
} ) ;
2025-11-25 21:04:22 +00:00
return {
available_days : availableDays ,
2025-11-27 19:18:59 +00:00
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" } ) ) ) ,
2025-11-25 21:04:22 +00:00
} as AdminAvailability ;
} catch ( error ) {
return null ;
}
}
2025-11-23 21:43:13 +00:00
// Get admin availability
export async function getAdminAvailability ( ) : Promise < AdminAvailability > {
const tokens = getStoredTokens ( ) ;
if ( ! tokens . access ) {
throw new Error ( "Authentication required." ) ;
}
2025-11-25 20:15:37 +00:00
const response = await fetch ( API_ENDPOINTS . meetings . adminAvailability , {
2025-11-23 21:43:13 +00:00
method : "GET" ,
headers : {
"Content-Type" : "application/json" ,
Authorization : ` Bearer ${ tokens . access } ` ,
} ,
} ) ;
2025-11-25 20:15:37 +00:00
const data : any = await response . json ( ) ;
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
2025-11-27 19:18:59 +00:00
// 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
2025-11-25 20:15:37 +00:00
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 ;
2025-11-23 21:43:13 +00:00
}
// Update admin availability
export async function updateAdminAvailability (
input : UpdateAvailabilityInput
) : Promise < AdminAvailability > {
const tokens = getStoredTokens ( ) ;
if ( ! tokens . access ) {
throw new Error ( "Authentication required." ) ;
}
2025-11-27 19:18:59 +00:00
// 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 )
2025-11-25 20:38:37 +00:00
? input . available_days . map ( day = > Number ( day ) )
2025-11-27 19:18:59 +00:00
: input . available_days ;
} else {
throw new Error ( "Either availability_schedule or available_days must be provided" ) ;
}
2025-11-25 20:38:37 +00:00
2025-11-27 19:18:59 +00:00
// 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 , {
2025-11-23 21:43:13 +00:00
method : "PUT" ,
headers : {
"Content-Type" : "application/json" ,
Authorization : ` Bearer ${ tokens . access } ` ,
} ,
2025-11-25 20:38:37 +00:00
body : JSON.stringify ( payload ) ,
2025-11-23 21:43:13 +00:00
} ) ;
2025-11-27 19:18:59 +00:00
// 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 ( ) ;
2025-11-25 20:38:37 +00:00
let data : any ;
2025-11-27 19:18:59 +00:00
// 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 ) ;
2025-11-25 20:38:37 +00:00
} catch ( parseError ) {
2025-11-27 19:18:59 +00:00
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 ) ;
2025-11-25 20:38:37 +00:00
}
2025-11-23 21:43:13 +00:00
if ( ! response . ok ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-27 19:18:59 +00:00
// 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 )
) ;
}
}
2025-11-25 20:38:37 +00:00
} ) ;
2025-11-27 19:18:59 +00:00
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 ( ) ;
2025-11-23 21:43:13 +00:00
}
2025-11-27 19:18:59 +00:00
// Handle legacy format
2025-11-25 20:38:37 +00:00
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 ;
2025-11-23 21:43:13 +00:00
}
// 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 ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
return data ;
}
2025-11-25 21:25:53 +00:00
// Get user appointment stats
2025-11-27 19:18:59 +00:00
export async function getUserAppointmentStats ( email : string ) : Promise < UserAppointmentStats > {
2025-11-25 21:25:53 +00:00
const tokens = getStoredTokens ( ) ;
if ( ! tokens . access ) {
throw new Error ( "Authentication required." ) ;
}
2025-11-27 19:18:59 +00:00
if ( ! email ) {
throw new Error ( "Email is required to fetch user appointment stats." ) ;
}
2025-11-25 21:25:53 +00:00
const response = await fetch ( API_ENDPOINTS . meetings . userAppointmentStats , {
2025-11-27 19:18:59 +00:00
method : "POST" ,
2025-11-25 21:25:53 +00:00
headers : {
"Content-Type" : "application/json" ,
Authorization : ` Bearer ${ tokens . access } ` ,
} ,
2025-11-27 19:18:59 +00:00
body : JSON.stringify ( { email } ) ,
2025-11-25 21:25:53 +00:00
} ) ;
2025-11-27 19:18:59 +00:00
const responseText = await response . text ( ) ;
2025-11-25 21:25:53 +00:00
if ( ! response . ok ) {
2025-11-27 19:18:59 +00:00
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 ) ;
2025-11-25 21:25:53 +00:00
throw new Error ( errorMessage ) ;
}
2025-11-27 19:18:59 +00:00
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 ` ) ;
}
2025-11-25 21:25:53 +00:00
return data ;
}
2025-11-23 21:43:13 +00:00
// 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 ) {
2025-11-24 16:38:09 +00:00
const errorMessage = extractErrorMessage ( data as unknown as ApiError ) ;
2025-11-23 21:43:13 +00:00
throw new Error ( errorMessage ) ;
}
return data ;
}