diff --git a/.env_sin_ruta_imagenes_api b/.env_sin_ruta_imagenes_api index 4eb2066..ecf5f20 100644 --- a/.env_sin_ruta_imagenes_api +++ b/.env_sin_ruta_imagenes_api @@ -1,11 +1,11 @@ PORT=3001 -DB_HOST=localhost +DB_HOST=194.164.175.51 DB_USER=roganet DB_PASSWORD=bdIRGLnet2905*/ DB_NAME=abian_app_produccion JWT_SECRET=9c64f2727d53bfefaaa17a5fda5009ffe93cae904860c659bd18d2d14ad6b467 -DB_HOST_P = localhost +DB_HOST_P = 194.164.175.51 DB_USER_P = roganet DB_PASSWORD_P = bdIRGLnet2905*/ DB_NAME_P = abian_app_produccion @@ -26,7 +26,7 @@ DRIVER_LICENSE_RETENTION_DAYS=365 #Carga de fotos dual TRIP_STATUS_PHOTO_STORAGE_MODE=local -TRIP_STATUS_SFTP_HOST=localhost +TRIP_STATUS_SFTP_HOST=194.164.175.51 TRIP_STATUS_SFTP_PORT=22 TRIP_STATUS_SFTP_USERNAME=ssh_fotos_estado TRIP_STATUS_SFTP_PASSWORD=IZYj%c0FiIlCc@rI%W0Z diff --git a/app.js b/app.js index c9fa6d8..9e6b70e 100644 --- a/app.js +++ b/app.js @@ -13,6 +13,7 @@ dotenv.config({ path: path.resolve(__dirname, '.env'), override: true }); const app = express(); const PORT = process.env.PORT || 3001; +const HOST = process.env.HOST || "127.0.0.1"; const uploadsDir = path.resolve(__dirname, 'uploads'); app.set('trust proxy', 1); @@ -88,6 +89,17 @@ const limiter = rateLimit({ legacyHeaders: false, message: "Too many requests from this IP, please try again after 15 minutes" }); +app.get(["/health", "/healthcheck"], (req, res) => { + res.set("Cache-Control", "no-store"); + res.status(200).json({ + status: "ok", + service: "node-gestion-api", + pid: process.pid, + uptime_seconds: Math.floor(process.uptime()), + timestamp_utc: new Date().toISOString() + }); +}); + app.use(limiter); // Routes diff --git a/docu/PUSH-API example 1.8.pdf b/docu/PUSH-API example 1.8.pdf new file mode 100644 index 0000000..6a5b209 Binary files /dev/null and b/docu/PUSH-API example 1.8.pdf differ diff --git a/docu/cambiar_estado.php b/docu/cambiar_estado.php new file mode 100644 index 0000000..737230d --- /dev/null +++ b/docu/cambiar_estado.php @@ -0,0 +1,191 @@ + 0) { + echo json_encode("0"); + die(); +} + +$sQuery2 = "UPDATE c_viajes SET id_estado = $id_estado, ind_edi_app = 1 WHERE id_viaje = $id_viaje"; +$rResult2 = mysqli_query($gaSql['link'], $sQuery2) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link'])); + +$sQuery2 = "INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, incidencia, latitud, longitud, fecha_y_hora, foto) VALUES ('" . $id_viaje . "','" . $n_proveedor . "','" . $usuario . "','" . $id_estado . "','" . $incidencia . "','" . $latitud . "','" . $longitud . "',NOW(),'" . $nombre . "')"; +$rResult2 = mysqli_query($gaSql['link'], $sQuery2) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link'])); + +$consulta = "SELECT id_viaje_padre, id_cliente, cod_viaje from c_viajes where id_viaje='" . $id_viaje . "'"; +$rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link'])); +$fila = mysqli_fetch_row($rResult2); +$id_viaje_padre = $fila[0]; +$id_cliente = $fila[1]; +$cod_viaje = $fila[2]; + +while ($id_viaje_padre > 0) { + $consulta = "UPDATE `c_viajes` SET id_estado='" . $id_estado . "', ind_edi_app = 1 where id_viaje='" . $id_viaje_padre . "'"; + $query_res = mysqli_query($gaSql['link'], $consulta); + + $consulta = "INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, incidencia, latitud, longitud, fecha_y_hora, foto) VALUES ('" . $id_viaje . "','" . $n_proveedor . "','" . $usuario . "','" . $id_estado . "','" . $incidencia . "','" . $latitud . "','" . $longitud . "',NOW(),'" . $nombre . "')"; + $query_res = mysqli_query($gaSql['link'], $consulta); + + //vover consultar para ver si se sale del bucle + $consulta = "SELECT id_viaje_padre, id_cr from c_viajes where id_viaje='" . $id_viaje_padre . "'"; + $rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link'])); + $fila = mysqli_fetch_row($rResult2); + $id_viaje_padre = $fila[0]; +} + +$resultado = "0"; + +$consulta = "SELECT html FROM html_correo WHERE id_mensaje = 34"; +$rResult2 = mysqli_query($gaSql['link'], $consulta); +$fila = mysqli_fetch_row($rResult2); +$mensaje = $fila[0]; + +$consulta = "SELECT user_smtp_admin, user_smtp_admin, pass_smtp_admin, host_smtp, puerto_smtp, date_format(NOW(), '%Y-%m-%d_%H_%i_%s') as ahora, email_operaciones FROM m_cr WHERE id_cr = $cr"; +$rResult2 = mysqli_query($gaSql['link'], $consulta); +$fila = mysqli_fetch_row($rResult2); +$email_operaciones = $fila[0]; +$user_smtp = $fila[1]; +$pass_smtp = $fila[2]; +$host_smtp = $fila[3]; +$puerto_smtp = $fila[4]; +$ahora = $fila[5]; +$email_operaciones = $fila[6]; + +$consulta = "SELECT estado FROM t_viaje_estados WHERE id_estado = $id_estado"; +$rResult2 = mysqli_query($gaSql['link'], $consulta); +$fila = mysqli_fetch_row($rResult2); +$estado = $fila[0]; + +if (strlen($user_smtp) > 4 && strlen($pass_smtp) > 4 && strlen($host_smtp) > 4) { + + $mail = new PHPMailer(); + $mail->IsSMTP(); + $mail->From = $user_smtp; + $mail->FromName = "Abian Service"; + + $html = '
Se ha cambiado el estado del viaje ' . $cod_viaje . ' a ' . $estado . ' mediante la APP'; + + $mail->AddAddress($email_operaciones); + $mail->SMTPAuth = true; + $mail->SMTPSecure = 'tls'; + $mail->Host = $host_smtp; + $mail->Port = $puerto_smtp; + $mail->Username = $user_smtp; + $mail->Password = $pass_smtp; + $mail->CharSet = 'UTF-8'; + $mail->Subject = "Facturas pendientes Abian Service"; + $mail->Body = $mensaje . "
" . $html; + $mail->IsHTML(true); + $mail->SMTPDebug = 0; + + if ($mail->Send()) { + echo json_encode("0"); + } else { + echo json_encode("Error al enviar el correo"); + } + +} else { + echo json_encode("Error al enviar el correo: fallo con usuario/contraseña"); +} + +if ($id_cliente == 532) { + $consulta = "SELECT id_tipovehiculo AS matricula FROM c_viajes_proveedor WHERE id_viaje = $id_viaje AND n_proveedor = $n_proveedor"; + $rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link'])); + $fila = mysqli_fetch_row($rResult2); + $matricula = $fila[0]; + + $xml = ' + + + + + + abian + TVwuW57u0^ + abian-5567 + 1 + + + + 1 + 5567 + +
+ ?fecha_hora? + 0 +
+ 14 + ' . $matricula . ' + + 12 + ' . $matricula . ' + 1 + 1 + ?latitud? + ?longitud? +
+
+
+
+
'; + + $url = 'https://export.fleetvisor.eu/wsQAP/Positions.svc/ssl'; + $str = date("Y-m-d"); + $strb = date("H:i:s"); + + $fecha_hora = $str . "T" . $strb . "Z"; + + $xml = str_replace("?fecha_hora?", $fecha_hora, $xml); + $xml = str_replace("?latitud?", $latitud, $xml); + $xml = str_replace("?longitud?", $longitud, $xml); + + //echo $xml; + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_URL, $url); + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + + $headers = array( + "Content-Type: text/xml", + "SOAPAction: QESE.QFV.QAP/PositionsService/SubmitPosition" + ); + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + + $data = $xml; + + curl_setopt($curl, CURLOPT_POSTFIELDS, $data); + + $resp = curl_exec($curl); + curl_close($curl); +} + +echo json_encode($resultado); +?> \ No newline at end of file diff --git a/docu/correo.txt b/docu/correo.txt new file mode 100644 index 0000000..5d10f33 --- /dev/null +++ b/docu/correo.txt @@ -0,0 +1,109 @@ + + +Dear Gorka, +thanks for your reply! + +We have a standard API that we provide openly - please see the attached documentation for more details. +The attached document also includes the URL and apikey for our TEST instance. + +- endpoint URL for test and production + +PROD URL: https://push-dhl.agheera.com/Telematics/positions +apiKey for Abian: 2NL7G-0QTMP-T0N54-7ERGY-S7U6U-SXKWF +TEST URL: https://push-test.agheera.com/Telematics/Positions +apiKey for testing purposes: 0eec610a-aad5-4f60-91c7-7f9a4089d5b5 + +- authentication method and credentials process +Authentication is done by sending an apikey in a HTTP header "apiKey" with each request. +There is no further handshake (oauth flow or similar). If the apikey is correct and accepted, data will be assigned to the customer (in this case Abian) +Apikey is provided by Agheera on request. +1 Apikey is bound to 1 customer and 1 environment (prod/test). + +- required payload format +Please see attached documentation for more details. +We encourage to send data batches whenever possible. + +- required vehicle identifier: license plate, device ID, MSISDN, etc. +Device ID and License Plate shall both be provided + +- timestamp format/timezone +Must be in UTC timezone +Format: yyyy-MM-ddTHH:mm:ssZ + +- required fields for position updates +Minimum required are: latitude, longitude, vehicleid(deviceid), licensePlate, measurementTime +We appreciate the following fields to have better Trip ETA calculation: speed, direction, assetType + +- expected response codes +200 - OK +If data was accepted and was syntactically correct +400 - Bad request +If data was syntactically wrong/malformed +401 - Unauthorized +Wrong apikey or apikey header missing +405 - Method not available +If request method was not POST + +- retry/error handling recommendations +We are not validating the semantics of position data at the point of response generation, so we return 200 - OK on almost all requests unless the request is badly malformed or unauthorized. +This also means a retry is not very likely needed, unless the Agheera server is down. +For sending position data to us, it is fine to "fire and forget" and not schedule a retry. +In case you notice any errors, please contact ops@agheera.com and we will investigate. + +- whether positions should be sent only for active DHL trips/customer id 532 or for all authorized vehicles +Agheera does already filter the data so that DHL can only see data that is relevant for DHL trips. +So as far as we are concerned, you can send for all vehicles, and we will filter it anyway. +But I think this should be decided by ABIAN if they want this. + +- any IP allowlist requirements +No IP whitelist in place. We do not need to know your IPs upfront. + + +Let me know if you have any open questions! + +Kind Regards / Freundliche Grüße +Stephan Wahlen +Head of IoT Hardware +Agheera GmbH – a DHL Group company + +stephan.wahlen@agheera.com ++49 2203 29757-23 +Office: August-Horch-Straße 5, 51149 Köln +Warehouse: Kasinostraße 24, 53840 Troisdorf, Deutschland +Registered office Cologne; Register court Bonn; HRB 18111 +VAT ID no. DE 273 231 181 +Managing Directors: Pierre Lynch, Sven Kefferpütz + + + +-----Ursprüngliche Nachricht----- +Von: Gorka Leceta +Gesendet: Donnerstag, 28. Mai 2026 11:52 +An: Operations ; Stephan Wahlen +Betreff: ABIAN SERVICE - Agheera Push API documentation request for GPS position integration + +Hello Stephan / Agheera team, + +We are Roganet, GPS provider for ABIAN SERVICE. + +ABIAN has asked us to integrate active position pushing from their mobile app backend to Agheera for DHL transports. + +Could you please send us the technical documentation for Agheera's push API? + +We need: +- endpoint URL for test and production +- authentication method and credentials process +- required payload format +- required vehicle identifier: license plate, device ID, MSISDN, etc. +- timestamp format/timezone +- required fields for position updates +- expected response codes +- retry/error handling recommendations +- whether positions should be sent only for active DHL trips/customer id +532 or for all authorized vehicles +- any IP allowlist requirements + +ABIAN registration was submitted on 2024-01-23 and the authorization document is AFTemplateV2-ABIAN SERVICE_2024-01-23T11_55_17Z.PDF. + +Kind regards + diff --git a/src/controllers/locationController.js b/src/controllers/locationController.js index 4d28570..7bdaf2b 100644 --- a/src/controllers/locationController.js +++ b/src/controllers/locationController.js @@ -1,4 +1,7 @@ const db = require('../config/db'); +const agheeraPushClient = require('../services/agheeraPushClient'); + +const AGHEERA_CLIENT_ID = 532; const getDniFromLocation = (locationData) => { if (locationData?.extras?.alias) { @@ -70,6 +73,137 @@ const getTripIdFromLocation = (locationData) => { return null; }; +const getRawTimestampFromLocation = (locationData) => { + const candidates = [ + locationData?.timestamp, + locationData?.location?.timestamp + ]; + + for (const value of candidates) { + if (value !== undefined && value !== null && String(value).trim() !== '') { + return String(value).trim(); + } + } + + return null; +}; + +const getPersistedTimestampFromLocation = (locationData) => { + const rawTimestamp = getRawTimestampFromLocation(locationData); + + if (rawTimestamp === null) { + return { + value: new Date(), + usedFallback: true, + reason: 'missing' + }; + } + + const parsedTimestamp = new Date(rawTimestamp); + if (Number.isNaN(parsedTimestamp.getTime())) { + return { + value: new Date(), + usedFallback: true, + reason: 'invalid', + rawTimestamp + }; + } + + return { + value: parsedTimestamp, + usedFallback: false, + reason: null, + rawTimestamp + }; +}; + + +const pushLocationToAgheera = async ({ latitude, longitude, dni, tripId, measurementTime }) => { + if (!tripId || !dni) { + return null; + } + + const [tripRows] = await db.query( + `SELECT id_cliente + FROM c_viajes + WHERE id_viaje = ? + LIMIT 1`, + [tripId] + ); + + if (Number.parseInt(tripRows[0]?.id_cliente, 10) !== AGHEERA_CLIENT_ID) { + return null; + } + + const baseResult = { + trip_id: tripId, + attempted: true, + success: false, + http_status: null, + error: null + }; + + const [authorizationRows] = await db.query( + `SELECT id_tipovehiculo AS matricula + FROM c_viajes_proveedor + WHERE id_viaje = ? + AND dni = ? + ORDER BY n_proveedor ASC + LIMIT 1`, + [tripId, dni] + ); + + const licensePlate = String(authorizationRows[0]?.matricula || '').trim(); + if (!licensePlate) { + return { + ...baseResult, + error: 'LICENSE_PLATE_NOT_FOUND' + }; + } + + try { + const result = await agheeraPushClient.pushPosition({ + latitude, + longitude, + vehicleId: licensePlate, + licensePlate, + measurementTime + }); + + return { + ...baseResult, + success: true, + http_status: result?.status ?? null + }; + } catch (error) { + console.error('Agheera push failed after location update:', { + tripId, + dni, + message: error.message, + status: error.status || null + }); + + return { + ...baseResult, + http_status: error.status || null, + error: error.message + }; + } +}; + +const pushLocationsToAgheera = async (locationsToPush) => { + const results = []; + + for (const location of locationsToPush) { + const result = await pushLocationToAgheera(location); + if (result) { + results.push(result); + } + } + + return results; +}; + const saveLocation = async (req, res) => { try { const data = req.body; @@ -100,8 +234,8 @@ const saveLocation = async (req, res) => { globalTripId = getTripIdFromLocation(data); } - const now = new Date(); const rowsToInsert = []; + const locationsToPush = []; for (const loc of locations) { const coords = getCoordinatesFromLocation(loc); @@ -109,13 +243,32 @@ const saveLocation = async (req, res) => { const tripId = globalTripId !== null ? globalTripId : getTripIdFromLocation(loc); if (coords && coords.lat !== undefined && coords.lat !== null && coords.lng !== undefined && coords.lng !== null) { + const persistedTimestamp = getPersistedTimestampFromLocation(loc); + + if (persistedTimestamp.usedFallback) { + console.warn('Location timestamp fallback applied:', { + reason: persistedTimestamp.reason, + rawTimestamp: persistedTimestamp.rawTimestamp || null, + uuid: loc?.uuid || loc?.location?.uuid || null, + tripId, + dni: dni || null + }); + } + rowsToInsert.push([ String(coords.lat), String(coords.lng), dni || null, - now, + persistedTimestamp.value, tripId ]); + locationsToPush.push({ + latitude: coords.lat, + longitude: coords.lng, + dni, + tripId, + measurementTime: persistedTimestamp.value + }); } } @@ -134,17 +287,24 @@ const saveLocation = async (req, res) => { [rowsToInsert] ); - return res.json({ + const agheeraResults = await pushLocationsToAgheera(locationsToPush); + const responseBody = { success: true, count: rowsToInsert.length, message: 'Locations saved' - }); + }; + + if (agheeraResults.length > 0) { + responseBody.agheera_push = agheeraResults.length === 1 ? agheeraResults[0] : agheeraResults; + } + + return res.json(responseBody); } catch (error) { console.error('Error saving location:', error); return res.status(500).json({ success: false, error: error.message }); } }; - -module.exports = { - saveLocation -}; + +module.exports = { + saveLocation +}; diff --git a/src/controllers/tripsController.js b/src/controllers/tripsController.js index 6801e53..d191aa8 100644 --- a/src/controllers/tripsController.js +++ b/src/controllers/tripsController.js @@ -2,6 +2,7 @@ const db = require('../config/db'); const fs = require('fs'); const path = require('path'); const tripIncidenceMailer = require('../services/tripIncidenceMailer'); +const agheeraPushClient = require('../services/agheeraPushClient'); const { collectUploadedTripStatusFiles, removeUploadedTripStatusFiles, @@ -23,6 +24,7 @@ const INTERMEDIATE_POINT_ALLOWED_STATES = [3, 4, 5]; const INTERMEDIATE_POINT_ALLOWED_STATES_SET = new Set(INTERMEDIATE_POINT_ALLOWED_STATES); const SQL_DATETIME_REGEX = /^(\d{4})-(\d{2})-(\d{2}) ([0-2]\d):([0-5]\d):([0-5]\d)$/; const FAILED_TRIP_STATE = 9; +const AGHEERA_CLIENT_ID = 532; const INTERMEDIATE_POINT_STATUS_IDS = new Set([3, 4, 5]); const INCIDENCE_TEXT_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; const GLOBAL_STATUS_KEYS_BY_STATE_ID = new Map([ @@ -238,6 +240,85 @@ const normalizeCoordinateValue = (rawValue) => { return String(parsedNumericValue); }; +const pushTripStatusPositionToAgheera = async ({ + tripRow, + authorizationRow, + latitud, + longitud, + measurementTime, + tripId, + requestId, + flow +}) => { + const clientId = Number.parseInt(tripRow?.id_cliente, 10); + if (clientId !== AGHEERA_CLIENT_ID) { + return null; + } + + const baseResult = { + trip_id: tripId, + attempted: false, + success: false, + http_status: null, + error: null + }; + + if (latitud === null || longitud === null) { + return { + ...baseResult, + error: 'COORDINATES_MISSING' + }; + } + + const licensePlate = String(authorizationRow?.matricula || '').trim(); + if (!licensePlate) { + return { + ...baseResult, + error: 'LICENSE_PLATE_NOT_FOUND' + }; + } + + try { + const result = await agheeraPushClient.pushPosition({ + latitude: latitud, + longitude: longitud, + vehicleId: licensePlate, + licensePlate, + measurementTime + }); + + appendTripStatusDebugLog({ + stage: 'update_trip_status:agheera_push_success', + request_id: requestId, + flow, + trip_id: tripId, + http_status: result?.status ?? null + }); + + return { + ...baseResult, + attempted: true, + success: true, + http_status: result?.status ?? null + }; + } catch (error) { + console.error('Agheera push failed after trip status update:', { + tripId, + requestId, + flow, + message: error.message, + status: error.status || null + }); + + return { + ...baseResult, + attempted: true, + http_status: error.status || null, + error: error.message + }; + } +}; + const normalizeSqlDateTimeValue = (rawValue) => { if (typeof rawValue !== 'string') { return null; @@ -1420,7 +1501,7 @@ const updateTripStatus = async (req, res) => { await connection.beginTransaction(); const [tripRows] = await connection.query( - `SELECT id_viaje, id_estado, id_viaje_padre + `SELECT id_viaje, id_estado, id_viaje_padre, id_cliente FROM c_viajes WHERE id_viaje = ? LIMIT 1 @@ -1453,7 +1534,7 @@ const updateTripStatus = async (req, res) => { } const [authorizationRows] = await connection.query( - `SELECT n_proveedor, id_proveedor + `SELECT n_proveedor, id_proveedor, id_tipovehiculo AS matricula FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? @@ -1716,8 +1797,18 @@ const updateTripStatus = async (req, res) => { idEstadoLogged: FAILED_TRIP_STATE, idPuntoLogged: idPunto }); + const agheeraPushResult = await pushTripStatusPositionToAgheera({ + tripRow: tripRows[0], + authorizationRow: authorizationRows[0], + latitud, + longitud, + measurementTime: now, + tripId, + requestId, + flow: 'failed_branch_manual' + }); - return res.status(200).json({ + const responseBody = { success: true, trip_id: tripId, updated_status_id: idEstado, @@ -1729,7 +1820,13 @@ const updateTripStatus = async (req, res) => { failed_marked: true, fotos_concat: fotosConcat || '', updated_at: now.toISOString() - }); + }; + + if (agheeraPushResult) { + responseBody.agheera_push = agheeraPushResult; + } + + return res.status(200).json(responseBody); } catch (error) { if (connection) { try { @@ -1752,7 +1849,7 @@ const updateTripStatus = async (req, res) => { } const [tripRows] = await db.query( - `SELECT id_viaje, id_viaje_padre + `SELECT id_viaje, id_viaje_padre, id_cliente FROM c_viajes WHERE id_viaje = ? LIMIT 1`, @@ -1783,7 +1880,7 @@ const updateTripStatus = async (req, res) => { } const [authorizationRows] = await db.query( - `SELECT n_proveedor, id_proveedor + `SELECT n_proveedor, id_proveedor, id_tipovehiculo AS matricula FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? @@ -1912,8 +2009,18 @@ const updateTripStatus = async (req, res) => { idEstadoLogged: idEstado, idPuntoLogged: idPunto }); + const agheeraPushResult = await pushTripStatusPositionToAgheera({ + tripRow: tripRows[0], + authorizationRow: authorizationRows[0], + latitud, + longitud, + measurementTime: now, + tripId, + requestId, + flow: 'normal_manual' + }); - return res.status(200).json({ + const responseBody = { success: true, trip_id: tripId, updated_status_id: idEstado, @@ -1925,7 +2032,13 @@ const updateTripStatus = async (req, res) => { failed_marked: false, fotos_concat: fotosConcat || '', updated_at: now.toISOString() - }); + }; + + if (agheeraPushResult) { + responseBody.agheera_push = agheeraPushResult; + } + + return res.status(200).json(responseBody); } catch (error) { const parsedTripId = Number.parseInt(req.params?.id, 10); const parsedStatusId = Number.parseInt(req.body?.id_estado, 10); diff --git a/src/services/agheeraPushClient.js b/src/services/agheeraPushClient.js new file mode 100644 index 0000000..eae14ff --- /dev/null +++ b/src/services/agheeraPushClient.js @@ -0,0 +1,113 @@ +const DEFAULT_AGHEERA_PUSH_URL = 'https://push-test.agheera.com/Telematics/Positions'; + +let httpClientOverride = null; + +const getPushUrl = () => + String(process.env.AGHEERA_PUSH_URL || DEFAULT_AGHEERA_PUSH_URL).trim(); + +const getApiKey = () => + String(process.env.AGHEERA_API_KEY || '').trim(); + +const formatMeasurementTime = (dateValue) => { + const date = dateValue instanceof Date ? dateValue : new Date(dateValue); + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +}; + +const normalizeNumber = (rawValue) => { + const numericValue = Number(rawValue); + return Number.isFinite(numericValue) ? numericValue : null; +}; + +const getHttpClient = () => { + if (typeof httpClientOverride === 'function') { + return httpClientOverride; + } + + if (typeof fetch === 'function') { + return fetch; + } + + return null; +}; + +const buildPositionPayload = ({ + latitude, + longitude, + vehicleId, + licensePlate, + measurementTime +}) => ({ + Vehicles: [ + { + latitude: normalizeNumber(latitude), + longitude: normalizeNumber(longitude), + vehicleId: String(vehicleId || '').trim(), + licensePlate: String(licensePlate || '').trim(), + measurementTime: formatMeasurementTime(measurementTime) + } + ] +}); + +const pushPosition = async ({ + latitude, + longitude, + vehicleId, + licensePlate, + measurementTime +}) => { + const httpClient = getHttpClient(); + if (!httpClient) { + throw new Error('Agheera HTTP client unavailable'); + } + + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Agheera API key missing'); + } + + const url = getPushUrl(); + const payload = buildPositionPayload({ + latitude, + longitude, + vehicleId, + licensePlate, + measurementTime + }); + + const response = await httpClient(url, { + method: 'POST', + headers: { + apiKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + const responseBody = typeof response?.text === 'function' ? await response.text() : ''; + if (!response?.ok) { + const error = new Error('Agheera push failed'); + error.status = response?.status || null; + error.body = responseBody; + throw error; + } + + return { + status: response.status, + body: responseBody + }; +}; + +const __setHttpClientForTests = (httpClient) => { + httpClientOverride = httpClient; +}; + +const __resetHttpClientForTests = () => { + httpClientOverride = null; +}; + +module.exports = { + DEFAULT_AGHEERA_PUSH_URL, + pushPosition, + __setHttpClientForTests, + __resetHttpClientForTests +}; diff --git a/test/location.agheera.integration.test.js b/test/location.agheera.integration.test.js new file mode 100644 index 0000000..02c905d --- /dev/null +++ b/test/location.agheera.integration.test.js @@ -0,0 +1,289 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const test = require('node:test'); +const jwt = require('jsonwebtoken'); + +const app = require('../app'); +const db = require('../src/config/db'); +const agheeraPushClient = require('../src/services/agheeraPushClient'); + +const TEST_JWT_SECRET = 'test-jwt-secret'; + +let originalQuery; +let originalJwtSecret; +let originalAgheeraApiKey; + +const createToken = (payload = {}) => + jwt.sign( + { + id: 1, + dni: '58045340X', + id_proveedor: 675, + ...payload + }, + TEST_JWT_SECRET, + { expiresIn: '1h' } + ); + +const withServer = async (callback) => + new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1'); + + server.on('error', reject); + server.on('listening', async () => { + try { + const result = await callback(server); + server.close((closeError) => { + if (closeError) { + reject(closeError); + return; + } + resolve(result); + }); + } catch (error) { + server.close(() => reject(error)); + } + }); + }); + +const postJson = async ({ port, path, authorization, body }) => + new Promise((resolve, reject) => { + const rawBody = JSON.stringify(body); + const req = http.request( + { + hostname: '127.0.0.1', + port, + method: 'POST', + path, + headers: { + authorization, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(rawBody) + } + }, + (res) => { + let responseBody = ''; + + res.on('data', (chunk) => { + responseBody += chunk; + }); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: responseBody ? JSON.parse(responseBody) : null + }); + }); + } + ); + + req.on('error', reject); + req.write(rawBody); + req.end(); + }); + + +test.before(() => { + originalQuery = db.query; + originalJwtSecret = process.env.JWT_SECRET; + originalAgheeraApiKey = process.env.AGHEERA_API_KEY; +}); + +test.after(() => { + db.query = originalQuery; + process.env.JWT_SECRET = originalJwtSecret; + process.env.AGHEERA_API_KEY = originalAgheeraApiKey; + agheeraPushClient.__resetHttpClientForTests(); +}); + +test.afterEach(() => { + db.query = originalQuery; + agheeraPushClient.__resetHttpClientForTests(); +}); + +test('POST /api/locations envia posicion a Agheera para cliente 532', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + process.env.AGHEERA_API_KEY = 'test-api-key'; + + const agheeraCalls = []; + agheeraPushClient.__setHttpClientForTests(async (url, options) => { + agheeraCalls.push({ url, options }); + return { ok: true, status: 200, text: async () => 'Messages received.' }; + }); + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /INSERT INTO c_trazabilidad_transportista/); + assert.equal(params[0].length, 1); + assert.deepEqual(params[0][0].slice(0, 3), ['40.416775', '-3.70379', '58045340X']); + assert.equal(params[0][0][4], 248230); + return [{ affectedRows: 1 }]; + } + + if (step === 2) { + assert.match(sql, /FROM c_viajes/); + assert.deepEqual(params, [248230]); + return [[{ id_cliente: 532 }]]; + } + + assert.match(sql, /FROM c_viajes_proveedor/); + assert.deepEqual(params, [248230, '58045340X']); + return [[{ matricula: '6599LCN' }]]; + }; + + const response = await withServer(async (server) => + postJson({ + port: server.address().port, + path: '/api/locations', + authorization: `Bearer ${createToken()}`, + body: { + latitude: 40.416775, + longitude: -3.70379, + id_viaje: 248230, + timestamp: '2026-06-01T13:20:00Z' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + success: true, + count: 1, + message: 'Locations saved', + agheera_push: { + trip_id: 248230, + attempted: true, + success: true, + http_status: 200, + error: null + } + }); + + assert.equal(agheeraCalls.length, 1); + + const call = agheeraCalls[0]; + assert.equal(call.url, 'https://push-test.agheera.com/Telematics/Positions'); + assert.equal(call.options.headers.apiKey, 'test-api-key'); + + const payload = JSON.parse(call.options.body); + assert.deepEqual(payload, { + Vehicles: [ + { + latitude: 40.416775, + longitude: -3.70379, + vehicleId: '6599LCN', + licensePlate: '6599LCN', + measurementTime: '2026-06-01T13:20:00Z' + } + ] + }); +}); + +test('POST /api/locations no envia a Agheera para clientes distintos de 532', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + process.env.AGHEERA_API_KEY = 'test-api-key'; + + const agheeraCalls = []; + agheeraPushClient.__setHttpClientForTests(async (url, options) => { + agheeraCalls.push({ url, options }); + return { ok: true, status: 200, text: async () => 'Messages received.' }; + }); + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /INSERT INTO c_trazabilidad_transportista/); + return [{ affectedRows: 1 }]; + } + + assert.match(sql, /FROM c_viajes/); + assert.deepEqual(params, [248230]); + return [[{ id_cliente: 700 }]]; + }; + + const response = await withServer(async (server) => + postJson({ + port: server.address().port, + path: '/api/locations', + authorization: `Bearer ${createToken()}`, + body: { + latitude: 40.416775, + longitude: -3.70379, + id_viaje: 248230, + timestamp: '2026-06-01T13:20:00Z' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + success: true, + count: 1, + message: 'Locations saved' + }); + assert.equal(agheeraCalls.length, 0); +}); + +test('POST /api/locations devuelve error de Agheera sin romper guardado local', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + process.env.AGHEERA_API_KEY = 'test-api-key'; + + agheeraPushClient.__setHttpClientForTests(async () => ({ + ok: false, + status: 401, + text: async () => 'Unauthorized' + })); + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /INSERT INTO c_trazabilidad_transportista/); + return [{ affectedRows: 1 }]; + } + + if (step === 2) { + assert.match(sql, /FROM c_viajes/); + assert.deepEqual(params, [248230]); + return [[{ id_cliente: 532 }]]; + } + + assert.match(sql, /FROM c_viajes_proveedor/); + assert.deepEqual(params, [248230, '58045340X']); + return [[{ matricula: '6599LCN' }]]; + }; + + const response = await withServer(async (server) => + postJson({ + port: server.address().port, + path: '/api/locations', + authorization: `Bearer ${createToken()}`, + body: { + latitude: 40.416775, + longitude: -3.70379, + id_viaje: 248230, + timestamp: '2026-06-01T13:20:00Z' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + success: true, + count: 1, + message: 'Locations saved', + agheera_push: { + trip_id: 248230, + attempted: true, + success: false, + http_status: 401, + error: 'Agheera push failed' + } + }); +}); diff --git a/test/trips.status.integration.test.js b/test/trips.status.integration.test.js index 1f893e9..227ab27 100644 --- a/test/trips.status.integration.test.js +++ b/test/trips.status.integration.test.js @@ -12,8 +12,11 @@ process.env.TRIP_STATUS_UPLOAD_DIR = TEST_UPLOAD_DIR; process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual' const app = require('../app'); +process.env.TRIP_STATUS_UPLOAD_DIR = TEST_UPLOAD_DIR; +process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; const db = require('../src/config/db'); const tripStatusPhotoStorage = require('../src/services/tripStatusPhotoStorage'); +const agheeraPushClient = require('../src/services/agheeraPushClient'); const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; process.env.JWT_SECRET = JWT_SECRET; @@ -233,6 +236,7 @@ test.after(() => { db.query = originalQuery; db.getConnection = originalGetConnection; tripStatusPhotoStorage.__resetSftpClientFactoryForTests(); + agheeraPushClient.__resetHttpClientForTests(); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; delete process.env.TRIP_STATUS_SFTP_HOST; delete process.env.TRIP_STATUS_SFTP_PORT; @@ -241,6 +245,8 @@ test.after(() => { delete process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR; delete process.env.POSTS_LOG_PATH; delete process.env.TRIP_STATUS_UPDATES_LOG_PATH; + delete process.env.AGHEERA_PUSH_URL; + delete process.env.AGHEERA_API_KEY; fs.rmSync(TEST_UPLOAD_DIR, { recursive: true, force: true }); fs.rmSync(TEST_POSTS_LOG_PATH, { force: true }); fs.rmSync(TEST_STATUS_LOG_PATH, { force: true }); @@ -250,6 +256,7 @@ test.afterEach(() => { db.query = originalQuery; db.getConnection = originalGetConnection; tripStatusPhotoStorage.__resetSftpClientFactoryForTests(); + agheeraPushClient.__resetHttpClientForTests(); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; delete process.env.TRIP_STATUS_SFTP_HOST; delete process.env.TRIP_STATUS_SFTP_PORT; @@ -258,6 +265,8 @@ test.afterEach(() => { delete process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR; delete process.env.POSTS_LOG_PATH; delete process.env.TRIP_STATUS_UPDATES_LOG_PATH; + delete process.env.AGHEERA_PUSH_URL; + delete process.env.AGHEERA_API_KEY; fs.rmSync(TEST_POSTS_LOG_PATH, { force: true }); fs.rmSync(TEST_STATUS_LOG_PATH, { force: true }); }); @@ -710,7 +719,7 @@ test('POST /api/trips/:id/status modo dual replica foto a SFTP y mantiene local' const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; - process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; + process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; @@ -787,7 +796,7 @@ test('POST /api/trips/:id/status modo dual con fallo SFTP mantiene fallback loca createFakeSftpClientFactory(recorder, { failPut: true }) ); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; - process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; + process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; @@ -853,7 +862,7 @@ test('POST /api/trips/:id/status payload inválido tras upload limpia remoto y l const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; - process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; + process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; @@ -1121,6 +1130,269 @@ test('POST /api/trips/:id/status propaga estado global al viaje padre', async () assert.equal(step, 7); }); +test('POST /api/trips/:id/status cliente 532 envia posicion a Agheera en estado global', async () => { + process.env.AGHEERA_API_KEY = 'test-api-key'; + const agheeraCalls = []; + agheeraPushClient.__setHttpClientForTests(async (url, options) => { + agheeraCalls.push({ url, options }); + return { + ok: true, + status: 200, + text: async () => 'Messages received.' + }; + }); + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /FROM t_viaje_estados/); + assert.deepEqual(params, [6]); + return [[{ id_estado: 6 }]]; + } + + if (step === 2) { + assert.match(sql, /FROM c_viajes/); + assert.match(sql, /id_cliente/); + assert.deepEqual(params, [248230]); + return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 532 }]]; + } + + if (step === 3) { + assert.match(sql, /FROM c_viajes_proveedor/); + assert.match(sql, /id_tipovehiculo AS matricula/); + assert.deepEqual(params, [248230, '58045340X']); + return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]]; + } + + if (step === 4) { + assert.match(sql, /UPDATE c_viajes/); + assert.deepEqual(params, [6, 1, 248230]); + return [{ affectedRows: 1 }]; + } + + assert.match(sql, /INSERT INTO c_cambios_estado/); + assert.equal(params[6], '40.416775'); + assert.equal(params[7], '-3.70379'); + return [{ insertId: 6, affectedRows: 1 }]; + }; + + const response = await withServer(async (server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/trips/248230/status', + authorization: `Bearer ${createToken()}`, + body: { + id_estado: 6, + latitud: '40,416775', + longitud: '-3.703790' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.success, true); + assert.deepEqual(response.body.agheera_push, { + trip_id: 248230, + attempted: true, + success: true, + http_status: 200, + error: null + }); + assert.equal(agheeraCalls.length, 1); + + const call = agheeraCalls[0]; + assert.equal(call.url, 'https://push-test.agheera.com/Telematics/Positions'); + assert.equal(call.options.method, 'POST'); + assert.equal(call.options.headers.apiKey, 'test-api-key'); + assert.equal(call.options.headers['Content-Type'], 'application/json'); + + const payload = JSON.parse(call.options.body); + assert.deepEqual(Object.keys(payload), ['Vehicles']); + assert.equal(payload.Vehicles.length, 1); + assert.equal(payload.Vehicles[0].latitude, 40.416775); + assert.equal(payload.Vehicles[0].longitude, -3.70379); + assert.equal(payload.Vehicles[0].vehicleId, '1234ABC'); + assert.equal(payload.Vehicles[0].licensePlate, '1234ABC'); + assert.match(payload.Vehicles[0].measurementTime, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); +}); + +test('POST /api/trips/:id/status cliente distinto de 532 no envia a Agheera', async () => { + process.env.AGHEERA_API_KEY = 'test-api-key'; + const agheeraCalls = []; + agheeraPushClient.__setHttpClientForTests(async (url, options) => { + agheeraCalls.push({ url, options }); + return { ok: true, status: 200, text: async () => 'Messages received.' }; + }); + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + return [[{ id_estado: 6 }]]; + } + + if (step === 2) { + return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 700 }]]; + } + + if (step === 3) { + return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]]; + } + + if (step === 4) { + assert.match(sql, /UPDATE c_viajes/); + return [{ affectedRows: 1 }]; + } + + assert.match(sql, /INSERT INTO c_cambios_estado/); + return [{ insertId: 6, affectedRows: 1 }]; + }; + + const response = await withServer(async (server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/trips/248230/status', + authorization: `Bearer ${createToken()}`, + body: { + id_estado: 6, + latitud: '40.416775', + longitud: '-3.703790' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.success, true); + assert.equal(response.body.agheera_push, undefined); + assert.equal(agheeraCalls.length, 0); +}); + +test('POST /api/trips/:id/status estado intermedio con id_punto no envia a Agheera', async () => { + process.env.AGHEERA_API_KEY = 'test-api-key'; + const agheeraCalls = []; + agheeraPushClient.__setHttpClientForTests(async (url, options) => { + agheeraCalls.push({ url, options }); + return { ok: true, status: 200, text: async () => 'Messages received.' }; + }); + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /FROM t_viaje_estados/); + return [[{ id_estado: 5 }]]; + } + + if (step === 2) { + assert.match(sql, /FROM c_viajes/); + return [[{ id_viaje: 248230 }]]; + } + + if (step === 3) { + assert.match(sql, /FROM c_viajes_proveedor/); + return [[{ n_proveedor: 1 }]]; + } + + if (step === 4) { + assert.match(sql, /FROM c_viajes_puntos/); + assert.deepEqual(params, [8123, 248230]); + return [[{ id_punto: 8123, id_estado_intermedio: 4, valor: null, foto: null }]]; + } + + assert.match(sql, /UPDATE c_viajes_puntos/); + return [{ affectedRows: 1 }]; + }; + + const response = await withServer(async (server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/trips/248230/status', + authorization: `Bearer ${createToken()}`, + body: { + id_estado: 5, + id_punto: 8123, + latitud: '40.416775', + longitud: '-3.703790' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.success, true); + assert.equal(response.body.agheera_push, undefined); + assert.equal(agheeraCalls.length, 0); +}); + +test('POST /api/trips/:id/status fallo de Agheera mantiene respuesta 200', async () => { + process.env.AGHEERA_API_KEY = 'test-api-key'; + const agheeraCalls = []; + agheeraPushClient.__setHttpClientForTests(async (url, options) => { + agheeraCalls.push({ url, options }); + return { + ok: false, + status: 500, + text: async () => 'temporary error' + }; + }); + + let step = 0; + db.query = async (sql) => { + step += 1; + + if (step === 1) { + return [[{ id_estado: 6 }]]; + } + + if (step === 2) { + return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 532 }]]; + } + + if (step === 3) { + return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]]; + } + + if (step === 4) { + assert.match(sql, /UPDATE c_viajes/); + return [{ affectedRows: 1 }]; + } + + assert.match(sql, /INSERT INTO c_cambios_estado/); + return [{ insertId: 6, affectedRows: 1 }]; + }; + + const response = await withServer(async (server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/trips/248230/status', + authorization: `Bearer ${createToken()}`, + body: { + id_estado: 6, + latitud: '40.416775', + longitud: '-3.703790' + } + }) + ); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.success, true); + assert.deepEqual(response.body.agheera_push, { + trip_id: 248230, + attempted: true, + success: false, + http_status: 500, + error: 'Agheera push failed' + }); + assert.equal(agheeraCalls.length, 1); +}); + test('POST /api/trips/:id/status estado intermedio con id_punto inválido => 400', async () => { db.query = async () => { throw new Error('db.query should not run for invalid id_punto'); @@ -2523,7 +2795,7 @@ test('DELETE /api/trips/:id/status en modo dual no borra foto en remoto ni local const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; - process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; + process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';