587 lines
21 KiB
JavaScript
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;
|