agmission/Development/server/services/satloc_service.js

587 lines
21 KiB
JavaScript

'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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<array>} 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<object>} 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<Buffer>} 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;