'use strict'; const BasePartnerService = require('./base_partner_service'); const axios = require('axios'); const path = require('path'); const { PartnerSystemUser, Partner } = require('../model/partner'); const partnerConfig = require('../helpers/partner_config'); const env = require('../helpers/env'); const { UserTypes, SystemTypes } = require('../helpers/constants'); const logger = require('../helpers/logger'); const { PartnerCodes } = require('../helpers/constants'); const pino = logger.child('satloc_service'); const fileSatlog = require('../helpers/file_satlog'); const redisCache = require('../helpers/redis_cache'); const { Errors } = require('../helpers/constants'); const { AppAuthError } = require('../helpers/app_error'); /** * SatLoc Partner Service * Manages communication with SatLoc Cloud API using partner system users */ class SatlocService extends BasePartnerService { constructor() { super(PartnerCodes.SATLOC); this.config = partnerConfig.getPartnerConfig(this.partnerCode); this.requestConfig = partnerConfig.getRequestConfig(this.partnerCode); // Use distributed Redis cache instead of in-memory cache this.cache = redisCache; this.healthCheckInterval = 30000; // 30 seconds this._partnerObjectId = null; // Cached partner _id to filter PSU queries by partner } /** * Get the storage path for SatLoc log files * Centralized method for path-agnostic storage management * @returns {string} Storage directory path */ getStoragePath() { return env.SATLOC_STORAGE_PATH || path.join(__dirname, '../uploads/satloc'); } /** * Resolve full file path from filename * Used for reconstructing paths from savedLocalFile (filename only) * @param {string} filename - Log filename (e.g., 'aircraft123_2025-12-10.log') * @returns {string} Full file path */ resolveLogFilePath(filename) { if (!filename) return null; return path.join(this.getStoragePath(), filename); } /** * Get authenticated API credentials for a customer with caching * @param {string} customerId - AgMission customer ID * @returns {object} API credentials and partner system user */ async getCustomerCredentials(customerId) { // Resolve the SatLoc partner ObjectId once and cache it on the instance if (!this._partnerId) { const partner = await Partner.findOne({ partnerCode: this.partnerCode }).select('_id').lean(); if (!partner) throw new Error(`Partner not found for partnerCode: ${this.partnerCode}`); this._partnerId = partner._id; } const partnerSystemUser = await PartnerSystemUser.findOne({ partner: this._partnerId, parent: customerId, active: true, markedDelete: { $ne: true } }); if (!partnerSystemUser) { throw new Error(`No active SatLoc system user found for customer: ${customerId}`); } const credentials = partnerConfig.getApiCredentials(partnerSystemUser, this.partnerCode); return { credentials, partnerSystemUser }; } /** * Get cached authentication data or authenticate and cache * Automatically retries once with fresh credentials if authentication fails * @param {string} customerId - AgMission customer ID * @param {object} options - Options { retryOnAuthError: boolean } * @returns {object} Cached auth data with userId and companyId */ async getCachedAuth(customerId, options = { retryOnAuthError: true }) { // Try to get cached authentication data from Redis const cached = await this.cache.getAuth(this.partnerCode, customerId); // Check if cache is valid (not expired and recent health check) if (cached && this.cache.isAuthValid(cached, this.healthCheckInterval)) { return cached; } // Cache miss or expired, authenticate and cache new data try { const { credentials } = await this.getCustomerCredentials(customerId); const authResult = await this.authenticateAndCache(credentials, customerId); return authResult; } catch (error) { // If authentication failed and retry is enabled, clear cache and retry once if (options.retryOnAuthError && this.isAuthError(error)) { pino.warn(`Authentication failed, clearing cache and retrying: customer=${customerId}, error=${error.message}`); // Clear stale cache await this.clearAuthCache(customerId); // Wait a bit before retry (allow for credential propagation) await new Promise(resolve => setTimeout(resolve, 3000)); // 3 second delay // Retry authentication with fresh credentials (disable retry to prevent infinite loop) const { credentials } = await this.getCustomerCredentials(customerId); const authResult = await this.authenticateAndCache(credentials, customerId); pino.info(`Authentication retry succeeded: customer=${customerId}`); return authResult; } // Not an auth error or retry disabled, propagate error throw error; } } /** * Clear authentication cache for a specific customer or all customers * @param {string} customerId - Optional customer ID to clear specific cache */ async clearAuthCache(customerId = null) { await this.cache.deleteAuth(this.partnerCode, customerId); } /** * Check if an error is authentication/authorization related * Based on ACTUAL SatLoc API testing (not assumptions!) * * Real API behavior discovered through testing: * 1. AuthenticateAPIUser with wrong credentials: * - HTTP 400 + empty string response + statusText: "Invalid Username or Password provide." * * 2. GetAircraftList/GetAircraftLogs with wrong userId/companyId/aircraftId: * - HTTP 400 + JSON response { "message": "The request is invalid." } * - These are parameter validation errors, NOT auth errors! * * 3. Server errors: * - HTTP 500 + empty string or JSON response * * @param {Error} error - Error object to check * @returns {boolean} True if error is auth-related (credentials), not parameter validation */ isAuthError(error) { if (!error) return false; // Check if error is AppAuthError (thrown by our authenticate() method) if (error.name === 'AppAuthError' || error.constructor.name === 'AppAuthError') { return true; } const status = error.response?.status; const statusText = (error.response?.statusText || '').toLowerCase(); const responseData = error.response?.data; // Check for authentication endpoint failure (HTTP 400 + empty string + specific statusText) if (status === 400 && responseData === '' && (statusText.includes('invalid username') || statusText.includes('invalid password') || statusText.includes('username or password'))) { return true; } // NOTE: HTTP 400 with JSON response like {"message": "The request is invalid."} // is NOT an auth error - it's a parameter validation error (wrong IDs) // These should NOT trigger cache clearing and retry! // Check error message from our code const message = (error.message || '').toLowerCase(); if (message.includes('authentication failed') || message.includes('wrong_credential') || message.includes('invalid credential')) { return true; } return false; } /** * Generate SatLoc-specific job ID * @param {object} job - Job object * @param {string} systemType - System type (G4 or BANTAM) * @returns {string} Generated SatLoc job ID */ generateJobId(job, systemType = SystemTypes.G4) { return fileSatlog.generateInternalJobName(job, systemType); } /** * Upload job data to aircraft in SatLoc system * @param {object} assignment - Job assignment with populated job and user data * @returns {Promise} Upload result with success status and SatLoc job ID */ async uploadJobDataToAircraft(assignment) { const customerId = assignment.user?.parent.toString(); try { if (!customerId) { return { success: false, message: 'No valid partner customer ID found', }; } const authData = await this.getCachedAuth(customerId); const systemType = assignment.user?.partnerInfo?.systemType || SystemTypes.G4; const satlocJobData = fileSatlog.createSatLocJob(assignment.job, systemType); if (!satlocJobData) { return { success: false, message: 'No valid polygon data found to create SatLoc job file', }; } // Ensure jobName ends with .job extension for SatLoc compatibility let jobName = assignment.extJobId; if (jobName && !jobName.toLowerCase().endsWith('.job')) { jobName = `${jobName}.job`; } const payload = { companyId: authData.companyId, userId: authData.userId, jobDataList: [ { // NOTE: not sure why i doesn't not accept non-guid value and what will it be used?, asked Bay via email August 15, 2025 // id: assignment._id.toString(), aircraftId: assignment.getPartnerAircraftId(), jobName: jobName, ...(assignment.notes && { notes: assignment.notes }), jobData: satlocJobData } ] }; // Make API call to SatLoc UploadJobData endpoint const response = await axios.post( `${this.config.apiEndpoint}/UploadJobData`, payload, { headers: { 'Content-Type': 'application/json', ...this.requestConfig.headers }, timeout: this.requestConfig.timeout } ); if (response.status === 200) { pino.debug(`Job successfully uploaded to SatLoc: assignment=${assignment._id}, job=${assignment.job._id}, externalJobId=${response.data.Result?.JobId}, jobDataSize=${satlocJobData.length}`); return { success: true, externalJobId: response.data.Result?.JobId, message: 'Job uploaded successfully to SatLoc', partnerResponse: response.data, jobFileContent: satlocJobData }; } else { const errorMessage = response.data?.ErrorMessage || response.data?.statusText || 'Unknown error from SatLoc API'; pino.debug(`SatLoc job upload failed: assignment=${assignment._id}, job=${assignment.job._id}, error=${errorMessage}`); return { success: false, message: errorMessage, partnerResponse: response.data }; } } catch (error) { pino.debug(`Error uploading job to SatLoc: assignment=${assignment._id}, job=${assignment.job._id}, error=${error.message}`); return { success: false, message: `Failed to upload job to SatLoc: ${error.message}`, error: error.message, isAuthError: this.isAuthError(error) // Flag for worker to know it's retryable }; } } /** * Health check for SatLoc API * @returns {Promise} Health status */ async healthCheck() { try { const response = await axios.get(`${this.config.apiEndpoint}/isAlive`, { timeout: 5000, headers: this.requestConfig.headers }); return { isAlive: response.status === 200, partnerCode: this.partnerCode, status: response.status, message: 'SatLoc API is accessible' }; } catch (error) { pino.debug(`SatLoc health check failed: ${error.message}`); return { isAlive: false, partnerCode: this.partnerCode, error: error.message, message: 'SatLoc API is not accessible' }; } } /** * Authenticate with SatLoc API without caching * * Real API behavior: * - Success: HTTP 200, response.data = { userId, companyId, email } * - Auth failure: HTTP 400, statusText = "Invalid Username or Password provide.", data = "" * - Server error: HTTP 500, data = { message: "An error has occurred." } * * @param {object} credentials - API credentials * @param {string} customerId - Customer ID for logging context * @returns {Promise} Authentication result without caching */ async authenticate(credentials, customerId) { const response = await axios.get(`${this.config.apiEndpoint}/AuthenticateAPIUser`, { params: { userLogin: credentials.username, password: credentials.password }, timeout: this.requestConfig.timeout, validateStatus: (status) => status < 500 // Accept all responses except server errors }); // Check for authentication failure // SatLoc returns 400 with empty string for invalid credentials if (response.status !== 200 || !response.data || typeof response.data !== 'object') { const errorMessage = response.statusText || `Authentication failed with status ${response.status}`; pino.debug(`SatLoc authentication failed: customer=${customerId}, status=${response.status}, statusText=${response.statusText}`); throw new AppAuthError(Errors.WRONG_CREDENTIAL, errorMessage); } // Verify we got the required fields if (!response.data.userId || !response.data.companyId) { const errorMessage = 'Authentication response missing userId or companyId'; pino.debug(`SatLoc authentication failed: customer=${customerId}, error=${errorMessage}, data=${JSON.stringify(response.data)}`); throw new AppAuthError(Errors.WRONG_CREDENTIAL, errorMessage); } const now = Date.now(); return { userId: response.data.userId, companyId: response.data.companyId, email: response.data.email, expiresAt: now + (3600 * 1000), // 1 hour lastHealthCheck: now }; } /** * Authenticate with SatLoc API and cache the result * @param {object} credentials - API credentials * @param {string} customerId - Customer ID for caching * @returns {Promise} Authentication result with caching */ async authenticateAndCache(credentials, customerId) { try { // Use the non-caching authenticate method const authData = await this.authenticate(credentials, customerId); // Cache the authentication data in Redis with TTL const expiresIn = authData.expiresAt - Date.now(); const ttlSeconds = Math.floor(expiresIn / 1000); // Convert to seconds await this.cache.setAuth(this.partnerCode, customerId, authData, ttlSeconds); return authData; } catch (error) { // Error already logged in authenticate() method throw error; } } /** * Get aircraft list from SatLoc system * @param {string} customerId - AgMission customer ID * @returns {Promise} Aircraft list response */ async getAircraftList(customerId) { try { const authData = await this.getCachedAuth(customerId); // Make API call to SatLoc GetAircraftList endpoint const response = await axios.get( `${this.config.apiEndpoint}/GetAircraftList`, { params: { userId: authData.userId, companyId: authData.companyId }, ...this.requestConfig } ); pino.debug('SatLoc GetAircraftList success', `customer=${customerId}, aircraftCount=${response.data?.length || 0}`); return { success: true, aircraft: response.data || [], partnerCode: this.partnerCode }; } catch (error) { pino.debug('SatLoc GetAircraftList failed', `customer=${customerId}, error=${error.message}, status=${error.response?.status}`); return { success: false, error: error.message, partnerCode: this.partnerCode }; } } /** * Get specific aircraft logs for an aircraft * @param {string} customerId - Customer ID * @param {string} aircraftId - Aircraft ID (partnerAircraftId) * @returns {Promise} Available logs for the aircraft */ async getAircraftLogs(customerId, aircraftId) { try { const authData = await this.getCachedAuth(customerId); if (!authData || !authData.userId) { pino.debug('SatLoc GetAircraftLogs failed - no auth data', `customer=${customerId}, aircraft=${aircraftId}`); return []; } const response = await axios.get( `${this.config.apiEndpoint}/GetAircraftLogs`, { params: { userId: authData.userId, aircraftId: aircraftId }, ...this.requestConfig } ); pino.debug('SatLoc GetAircraftLogs success', `customer=${customerId}, userId=${authData.userId}, aircraft=${aircraftId}, logsCount=${response.data?.length || 0}`); // Normalize log filenames - ensure .log extension is present const logs = response.data || []; return logs.map(log => { if (log.logFileName && !log.logFileName.toLowerCase().endsWith('.log')) { return { ...log, logFileName: `${log.logFileName}.log` }; } return log; }); } catch (error) { pino.debug('SatLoc GetAircraftLogs failed', `customer=${customerId}, aircraft=${aircraftId}, error=${error.message}, status=${error.response?.status}`); return []; } } /** * Download specific aircraft log data from SatLoc * @param {string} customerId - AgMission customer ID * @param {string} logId - SatLoc log ID * @returns {Promise} Log data with binary content */ async getAircraftLogData(customerId, logId) { try { const authData = await this.getCachedAuth(customerId); if (!authData || !authData.userId) { throw new Error(`No valid authentication for customer: ${customerId}`); } // SatLoc API endpoint for downloading log data const url = `${this.config.apiEndpoint}/GetAircraftLogData`; pino.debug(`SatLoc GetAircraftLogData request: customer=${customerId}, userId=${authData.userId}, logId=${logId}`); const response = await axios.get(url, { params: { userId: authData.userId, logId: logId } }); pino.debug('SatLoc GetAircraftLogData success'); // Return log data as Buffer with metadata const logFileBuffer = Buffer.from(response.data.logFile, 'base64'); return { logId: logId, logFile: logFileBuffer, logFileName: response.data.logFileName, contentType: response.headers['content-type'] || 'application/octet-stream', contentLength: logFileBuffer.length }; } catch (error) { pino.error({ err: error, customerId, logId }, `SatLoc GetAircraftLogData failed: customer=${customerId}, logId=${logId}, error=${error.message}, status=${error.response?.status}`); throw new Error(`Failed to download log data: ${error.message}`); } } /** * Fetch log file from partner storage * @param {string} logFileName - Name of the log file to fetch * @param {string} partnerCode - Partner code (defaults to SATLOC) * @returns {Promise} Log file content as buffer */ async fetchLogFile(logFileName, partnerCode = PartnerCodes.SATLOC) { try { const fs = require('fs').promises; const logFilePath = this.resolveLogFilePath(logFileName); if (!logFilePath) { throw new Error(`Invalid log filename: ${logFileName}`); } pino.debug(`Fetching log file from storage: ${logFilePath}`); // Check if file exists and is readable try { const stats = await fs.stat(logFilePath); if (!stats.isFile()) { throw new Error(`Path is not a file: ${logFilePath}`); } // Check file age against maxFileAge if configured if (config.storage.maxFileAge) { const fileAge = Date.now() - stats.mtime.getTime(); if (fileAge > config.storage.maxFileAge) { pino.warn(`Log file is older than maxFileAge: ${logFilePath}, age=${fileAge}ms`); } } // Validate file extension const fileExt = path.extname(logFileName).toLowerCase(); const validExtensions = config.storage.logFileExtensions || ['.log']; if (!validExtensions.some(ext => ext.toLowerCase() === fileExt)) { throw new Error(`Invalid log file extension: ${fileExt}. Allowed: ${validExtensions.join(', ')}`); } } catch (statError) { if (statError.code === 'ENOENT') { throw new Error(`Log file not found: ${logFilePath}`); } if (statError.code === 'EACCES') { throw new Error(`Permission denied accessing log file: ${logFilePath}`); } throw statError; } // Read the file const fileBuffer = await fs.readFile(logFilePath); pino.debug(`Successfully fetched log file: ${logFilePath}, size=${fileBuffer.length} bytes`); return { fileName: logFileName, filePath: logFilePath, content: fileBuffer, size: fileBuffer.length, mtime: (await fs.stat(logFilePath)).mtime }; } catch (error) { pino.error({ err: error, logFileName, partnerCode }, `Failed to fetch log file: ${logFileName}, error=${error.message}`); throw new Error(`Failed to fetch log file: ${error.message}`); } } } module.exports = SatlocService;