/** * 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'; // CRITICAL: Track in-flight requests to prevent duplicate API calls // This ensures if multiple calls are made for the same email/state/etc, we reuse the same promise this._pendingRequests = new Map(); // Map of cacheKey -> Promise } /** * Make API request through backend proxy * @private */ async makeRequest(action, params = {}, signal = null) { let timeoutId = null; try { // CRITICAL: Ensure proxyUrl is defined if (!this.proxyUrl) { throw new Error('AgileEdAPI: proxyUrl is not defined. Please provide proxyUrl in constructor config.'); } // 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); let response; try { response = await fetch(this.proxyUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, params: params }), signal: controller.signal }); } catch (fetchError) { // CRITICAL: Handle CSP violations and network errors gracefully // Fix: Always clear timeout, even on error if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } const errorMessage = fetchError?.message || String(fetchError || ''); const isCSPViolation = errorMessage.includes('Content Security Policy') || errorMessage.includes('CSP') || errorMessage.includes('violates the following Content Security Policy'); const isNetworkError = errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError') || errorMessage.includes('Network request failed'); if (isCSPViolation) { throw new Error(`Content Security Policy violation: The API proxy URL (${this.proxyUrl}) is not allowed by the site's CSP policy. Please contact your administrator to add it to the connect-src directive.`); } else if (isNetworkError) { throw new Error(`Network error: Unable to connect to API proxy. Please check your internet connection and try again.`); } else { throw new Error(`API request failed: ${errorMessage}`); } } // 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; let responseText = ''; try { responseText = await response.text(); try { errorData = JSON.parse(responseText); } catch (parseError) { // Response is not valid JSON - log the raw text errorData = { error: `Server error: ${response.status}`, rawResponse: responseText.substring(0, 500) }; } } catch (textError) { errorData = { error: `Server error: ${response.status} - Unable to read response` }; } // 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 with full details 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) { // CRITICAL: Check if there's already an in-flight request for this state // This prevents duplicate API calls when multiple parts of code request the same state simultaneously const requestKey = `districts:${stateCode.toUpperCase()}`; if (this._pendingRequests.has(requestKey)) { // Return the existing promise instead of making a new request try { return await this._pendingRequests.get(requestKey); } catch (error) { // If the previous request failed, remove it and try again this._pendingRequests.delete(requestKey); throw error; } } // Create new request and store the promise const requestPromise = this.makeRequest('getDistrictsInState', { stateCode: stateCode }, signal).then(result => { // Remove from pending requests on success this._pendingRequests.delete(requestKey); return result; }).catch(error => { // Remove from pending requests on error this._pendingRequests.delete(requestKey); throw error; }); this._pendingRequests.set(requestKey, requestPromise); return requestPromise; } /** * 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); // CRITICAL: Check if there's already an in-flight request for this email // This prevents duplicate API calls when multiple parts of code request the same data simultaneously const requestKey = `personnel:${sanitizedEmail.toLowerCase()}`; if (this._pendingRequests.has(requestKey)) { // Return the existing promise instead of making a new request try { return await this._pendingRequests.get(requestKey); } catch (error) { // If the previous request failed, remove it and try again this._pendingRequests.delete(requestKey); throw error; } } // Create new request and store the promise const requestPromise = this.makeRequest('getPersonnelByEmail', { email: sanitizedEmail }).then(result => { // Remove from pending requests on success this._pendingRequests.delete(requestKey); return result; }).catch(error => { // Remove from pending requests on error this._pendingRequests.delete(requestKey); throw error; }); this._pendingRequests.set(requestKey, requestPromise); return requestPromise; } /** * 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 }); } }