/** * Agile-Ed API Client (Backend Proxy Version) * * This version calls a backend proxy API instead of directly calling Agile-Ed API. * This keeps credentials secure on the server. */ class AgileEdAPI { constructor(config = {}) { this.proxyUrl = config.proxyUrl || '/api/agile-ed-proxy.php'; } /** * Make API request through backend proxy * @private */ async makeRequest(action, params = {}, signal = null) { let timeoutId = null; try { // Log the request being made // Add timeout to fetch request (40 seconds to match PHP timeout of 30s + buffer) const controller = signal || new AbortController(); timeoutId = setTimeout(() => controller.abort(), 40000); const response = await fetch(this.proxyUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, params: params }), signal: controller.signal }); // Fix: Always clear timeout, even on error if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } // Handle different response statuses if (response.status === 500 || response.status === 400) { // Server error - try to get error message let errorData; try { const text = await response.text(); errorData = JSON.parse(text); } catch (parseError) { errorData = { error: `Server error: ${response.status}` }; } // Check if this is a subscription error FIRST let errorMsg = errorData.error || `Server error: ${response.status}`; const isSubscriptionError = errorMsg.includes('INACTIVE SUBSCRIPTION') || errorMsg.includes('SUBSCRIPTION DATES') || errorMsg.includes('SUBSCRIPTION ISSUE') || errorMsg.includes('subscription is inactive') || errorMsg.includes('subscription is expired'); if (isSubscriptionError) { // Log as info, not error - this is expected throw new Error(errorMsg); } // For non-subscription errors, log as error if (errorData.file && errorData.line) { errorMsg += `\nFile: ${errorData.file}, Line: ${errorData.line}`; } if (errorData.trace) { } throw new Error(errorMsg); } if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); const errorMessage = errorData.error || `API error: ${response.status}`; // Handle specific error cases if (response.status === 400 && (errorMessage.includes('CALL LIMIT EXCEEDED') || errorMessage.includes('LIMIT EXCEEDED'))) { // This is likely a rate limit (too many calls in short time), not the annual limit throw new Error('Rate limit exceeded (too many requests in a short time). Please wait a few seconds and try again.'); } else if (response.status === 429) { throw new Error('Too many requests. Please wait a moment and try again.'); } else if (response.status === 403) { throw new Error('API access denied. Please contact support.'); } throw new Error(errorMessage); } let data; try { const textResponse = await response.text(); if (!textResponse || textResponse.trim().length === 0) { throw new Error('Empty response from server. The API may be experiencing issues.'); } data = JSON.parse(textResponse); } catch (jsonError) { // Fix: Better error handling for JSON parse failures const textResponse = await response.text().catch(() => 'Unable to read response'); const errorMsg = jsonError.message || 'Invalid JSON response'; throw new Error(`Server response error: ${errorMsg}. Response: ${textResponse.substring(0, 200)}`); } if (!data.success) { // Check for subscription errors FIRST before logging as error let errorMsg = data.error || 'Request failed'; const isSubscriptionError = errorMsg.includes('INACTIVE SUBSCRIPTION') || errorMsg.includes('SUBSCRIPTION DATES') || errorMsg.includes('SUBSCRIPTION ISSUE') || errorMsg.includes('subscription is inactive') || errorMsg.includes('subscription is expired'); if (isSubscriptionError) { // Log as info, not error - this is expected when subscription is inactive errorMsg = 'API Subscription Issue: The Agile-Ed API subscription is inactive or expired. Please contact your administrator to renew the subscription.'; } else { // Only log as error for non-subscription errors if (data.file && data.line) { errorMsg += ` (File: ${data.file}, Line: ${data.line})`; } } throw new Error(errorMsg); } // Return data (can be null for 204 No Content responses) return data.data; } catch (error) { // Fix: Ensure timeout is cleared even on error if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } // Check for subscription errors FIRST before logging as error const errorMsg = error.message || error.toString(); const isSubscriptionError = errorMsg.includes('INACTIVE SUBSCRIPTION') || errorMsg.includes('SUBSCRIPTION DATES') || errorMsg.includes('SUBSCRIPTION ISSUE') || errorMsg.includes('subscription is inactive') || errorMsg.includes('subscription is expired'); if (isSubscriptionError) { // Log as info, not error - this is expected when subscription is inactive // Don't log as error to avoid cluttering console // Create a custom error that won't show as red error in console const subscriptionError = new Error(errorMsg); subscriptionError.name = 'SubscriptionError'; // Mark as subscription error subscriptionError.isSubscriptionError = true; // Add flag for easy detection throw subscriptionError; } // Handle timeout/abort errors specifically if (error.name === 'AbortError' || error.message.includes('timeout')) { // Log as info instead of error to reduce console spam // Timeouts are expected for slow API responses throw new Error('Request timed out. The API may be slow to respond. Please try again.'); } // Log other errors normally throw error; } } /** * Search by ZIP code * @param {string} type - Institution type (K12, HE, ECC, ALL) * @param {string} zip - ZIP code * @param {Object} options - Optional: firstName, lastName, emailAddress * @returns {Promise} Array of institutions */ async searchByZip(type, zip, options = {}) { return await this.makeRequest('searchByZip', { type: type, zip: zip, ...options }); } /** * Search by email address * @param {string} email - Email address * @param {AbortSignal} signal - Optional abort signal for request cancellation * @returns {Promise} Institution data */ async searchByEmail(email, signal = null) { // FIX #8: Sanitize email input const sanitizeEmail = (email) => { if (!email || typeof email !== 'string') return ''; return email.replace(/[<>\"']/g, '').trim(); }; return await this.makeRequest('searchByEmail', { email: sanitizeEmail(email) }, signal); } /** * Search by domain * @param {string} domain - Domain name (e.g., "philasd.org") * @returns {Promise} Array of institutions */ async searchByDomain(domain) { return await this.makeRequest('searchByDomain', { domain: domain }); } /** * Get institution by UID * @param {string} type - Institution type (K12, HE, ECC) * @param {string} uid - Unique identifier * @returns {Promise} Institution data */ async getByUid(type, uid) { return await this.makeRequest('getByUid', { type: type, uid: uid }); } /** * Get districts in a state * @param {string} stateCode - Two-letter state code (e.g., "CO", "AR") * @param {AbortSignal} signal - Optional abort signal for request cancellation * @returns {Promise} Array of districts */ async getDistrictsInState(stateCode, signal = null) { return await this.makeRequest('getDistrictsInState', { stateCode: stateCode }, signal); } /** * Get buildings in a district * @param {string} districtUid - District unique identifier * @returns {Promise} Array of buildings/schools */ async getBuildingsInDistrict(districtUid) { return await this.makeRequest('getBuildingsInDistrict', { districtUid: districtUid }); } /** * Get personnel by email (returns title/job title) * @param {string} email - Email address * @returns {Promise} Personnel data with title */ async getPersonnelByEmail(email) { const sanitizeEmail = (email) => { if (!email || typeof email !== 'string') return ''; return email.replace(/[<>\"']/g, '').trim(); }; const sanitizedEmail = sanitizeEmail(email); const result = await this.makeRequest('getPersonnelByEmail', { email: sanitizedEmail }); return result; } /** * Get building details in district (returns districtStudents) * @param {string} uid - Building UID * @returns {Promise} Building data with districtStudents */ async getBuildingInDistrict(uid) { return await this.makeRequest('getBuildingInDistrict', { uid: uid }); } }