website/lib/actions/appointments.ts

740 lines
24 KiB
TypeScript
Raw Normal View History

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<any> {
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(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
responseText.match(/<h1[^>]*>([\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(/<pre[^>]*>([\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<string, string[]> {
if (typeof schedule === 'string') {
try {
schedule = JSON.parse(schedule);
} catch {
return {};
}
}
const numberToTimeSlot: Record<number, string> = {
0: 'morning',
1: 'afternoon',
2: 'evening',
};
const result: Record<string, string[]> = {};
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<Appointment> {
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<AvailableDatesResponse> {
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<WeeklyAvailabilityResponse> {
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<AvailabilityConfig> {
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<CheckDateAvailabilityResponse> {
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<AvailabilityOverview> {
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<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 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<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 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<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 = 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<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
// Build payload with defaults
const payload: any = {
...input,
// Default create_jitsi_meeting to true if not specified
create_jitsi_meeting: input.create_jitsi_meeting !== undefined ? input.create_jitsi_meeting : true,
};
// Remove undefined fields
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/schedule/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
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<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
// Build payload - only include rejection_reason if provided
const payload: any = {};
if (input.rejection_reason) {
payload.rejection_reason = input.rejection_reason;
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reject/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
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<AdminAvailability | null> {
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<string, string[]> = {};
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<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 = 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<AdminAvailability> {
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<string, string[]> = {};
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<string, string[]> = {};
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<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 = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
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<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 = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}