website/lib/actions/appointments.ts

672 lines
19 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,
AppointmentsListResponse,
AvailableDatesResponse,
AdminAvailability,
AppointmentStats,
UserAppointmentStats,
JitsiMeetingInfo,
ApiError,
} 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");
}
if (!input.preferred_dates || input.preferred_dates.length === 0) {
throw new Error("At least one preferred date is required");
}
if (!input.preferred_time_slots || input.preferred_time_slots.length === 0) {
throw new Error("At least one preferred time slot is required");
}
// Validate date format (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
for (const date of input.preferred_dates) {
if (!dateRegex.test(date)) {
throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD format.`);
}
}
// Validate time slots
const validTimeSlots = ["morning", "afternoon", "evening"];
for (const slot of input.preferred_time_slots) {
if (!validTimeSlots.includes(slot)) {
throw new Error(`Invalid time slot: ${slot}. Must be one of: ${validTimeSlots.join(", ")}`);
}
}
// Prepare the payload exactly as the API expects
// Only include fields that the API accepts - no jitsi_room_id or other fields
const payload: {
first_name: string;
last_name: string;
email: string;
preferred_dates: string[];
preferred_time_slots: string[];
phone?: string;
reason?: string;
} = {
first_name: input.first_name.trim(),
last_name: input.last_name.trim(),
email: input.email.trim().toLowerCase(),
preferred_dates: input.preferred_dates,
preferred_time_slots: input.preferred_time_slots,
};
// Only add optional fields if they have values
if (input.phone && input.phone.trim()) {
payload.phone = input.phone.trim();
}
if (input.reason && input.reason.trim()) {
payload.reason = input.reason.trim();
}
// Log the payload for debugging
console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2));
console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment);
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST",
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();
// 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) {
// If JSON parsing fails, log the actual response
console.error("Failed to parse JSON response:", {
status: response.status,
statusText: response.statusText,
contentType,
url: API_ENDPOINTS.meetings.createAppointment,
preview: responseText.substring(0, 500)
});
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
const errorMatch = responseText.match(/<pre[^>]*>(.*?)<\/pre>/is) ||
responseText.match(/<h1[^>]*>(.*?)<\/h1>/is) ||
responseText.match(/<title[^>]*>(.*?)<\/title>/is);
if (errorMatch && errorMatch[1]) {
const htmlError = errorMatch[1].replace(/<[^>]*>/g, '').trim();
if (htmlError) {
errorMessage += `. ${htmlError}`;
}
}
console.error("Non-JSON response received:", {
status: response.status,
statusText: response.statusText,
contentType,
url: API_ENDPOINTS.meetings.createAppointment,
payload: input,
preview: responseText.substring(0, 1000)
});
throw new Error(errorMessage);
}
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
// Handle different response formats
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
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data: AvailableDatesResponse | string[] = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
// If API returns array directly, wrap it in response object
if (Array.isArray(data)) {
return {
dates: data,
};
}
return data as AvailableDatesResponse;
}
// 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 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 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'}`;
}
console.error("Schedule appointment error:", {
status: response.status,
statusText: response.statusText,
data,
errorMessage,
});
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 - tries without auth first)
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
try {
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
return null;
}
const data: any = await response.json();
// Handle both string and array formats for available_days
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;
} 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 both string and array formats for available_days
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.");
}
// Ensure available_days is an array of numbers
const payload = {
available_days: Array.isArray(input.available_days)
? input.available_days.map(day => Number(day))
: input.available_days
};
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
let data: any;
try {
data = await response.json();
} catch (parseError) {
// If response is not JSON, use status text
throw new Error(response.statusText || "Failed to update availability");
}
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
console.error("Availability update error:", {
status: response.status,
statusText: response.statusText,
data,
payload
});
throw new Error(errorMessage);
}
// Handle both string and array formats for available_days in response
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(): 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: UserAppointmentStats = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
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;
}