From 5a5ba850ba615c3958c467f936487072592af4eb Mon Sep 17 00:00:00 2001 From: abiandev Date: Wed, 6 May 2026 17:44:28 +0200 Subject: [PATCH] falla desde api --- .env_sin_ruta_imagenes_api | 33 ++ README.md | 2 +- README_sin_ruta_mod_imagenes_api.md | 220 +++++++++++ src/middleware/tripStatusUpload.js | 18 +- .../tripStatusUpload_sin_ruta_imag_api.js | 153 ++++++++ src/services/tripStatusPhotoStorage.js | 42 +- ...tatusPhotoStorage_sin_ruta_mod_imag_api.js | 360 ++++++++++++++++++ 7 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 .env_sin_ruta_imagenes_api create mode 100644 README_sin_ruta_mod_imagenes_api.md create mode 100644 src/middleware/tripStatusUpload_sin_ruta_imag_api.js create mode 100644 src/services/tripStatusPhotoStorage_sin_ruta_mod_imag_api.js diff --git a/.env_sin_ruta_imagenes_api b/.env_sin_ruta_imagenes_api new file mode 100644 index 0000000..f10b00e --- /dev/null +++ b/.env_sin_ruta_imagenes_api @@ -0,0 +1,33 @@ +PORT=3001 +DB_HOST=194.164.175.51 +DB_USER=roganet +DB_PASSWORD=aB0iaN.2o17!0-dB$9 +DB_NAME=abian_app_produccion +JWT_SECRET=9c64f2727d53bfefaaa17a5fda5009ffe93cae904860c659bd18d2d14ad6b467 + +DB_HOST_P = 194.164.175.51 +DB_USER_P = roganet +DB_PASSWORD_P = aB0iaN.2o17!0-dB$9 +DB_NAME_P = abian_app_produccion + +# Firebase Admin (credenciales y config) +GOOGLE_APPLICATION_CREDENTIALS=/var/www/vhosts/gestion.abianservice.com/node.gestion.abianservice.com/notabser-firebase-adminsdk-fbsvc-bf88758663.json +FIREBASE_DATABASE_URL= + +# Notificación de prueba (FCM) +FCM_TEST_TOKEN= +FCM_TEST_TITLE=Prueba desde Node JS Firebase Admin +FCM_TEST_BODY=Notificación de prueba + +# Driver license secure storage +DRIVER_LICENSE_ENCRYPTION_KEY=8069a9d8008c5b4b9feaaa58a839789e04b09405f2505e1325babd8e4ab54e69 +DRIVER_LICENSE_ENCRYPTION_KEY_VERSION=v1 +DRIVER_LICENSE_RETENTION_DAYS=365 + +#Carga de fotos dual +TRIP_STATUS_PHOTO_STORAGE_MODE=local +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 +TRIP_STATUS_SFTP_REMOTE_BASE_DIR=/fotos_estado_react_native \ No newline at end of file diff --git a/README.md b/README.md index 8cd36d0..079aa25 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ npm run notify:test -- --token "" --dry-run Para `POST /api/trips/:id/status`, las fotos pueden guardarse en local y replicarse por SFTP en paralelo. -- `TRIP_STATUS_UPLOAD_DIR` (opcional): base local. +- `TRIP_STATUS_UPLOAD_DIR` (opcional): base local. Admite una ruta o varias rutas candidatas separadas por `;`; si son relativas, se resuelven desde la raiz del proyecto Node. Default: `uploads/trips/status` dentro del proyecto. - `TRIP_STATUS_PHOTO_STORAGE_MODE` (opcional): `local` o `dual`. Default: `local`. diff --git a/README_sin_ruta_mod_imagenes_api.md b/README_sin_ruta_mod_imagenes_api.md new file mode 100644 index 0000000..8cd36d0 --- /dev/null +++ b/README_sin_ruta_mod_imagenes_api.md @@ -0,0 +1,220 @@ +# tablas que tenemos que actualizar en la base de datos de producción + +c_cambios_estado +c_cambios_estado_eliminados +c_viajes_puntos +c_viajes_puntos_tracking +c_viajes_incidencias +v_viajes_incidencias + +trg_c_viajes_puntos_tracking_au +trg_c_cambios_estado_bd + +# Node Gestión API – Firebase Admin + +Este proyecto incluye un script de prueba para enviar notificaciones con Firebase Admin SDK. + +## Configuración segura de credenciales + +La opción más segura para persistir `GOOGLE_APPLICATION_CREDENTIALS` en Linux es usar un archivo de entorno protegido y cargarlo desde tu gestor de procesos (systemd o PM2). Ejemplo con systemd: + +1. Crea un archivo de entorno fuera del repo, con permisos restrictivos: + +``` +# /etc/node-gestion-api.env +GOOGLE_APPLICATION_CREDENTIALS=/var/www/node.gestion.abianservice.com/notabser-firebase-adminsdk-fbsvc-bf88758663.json +FIREBASE_DATABASE_URL=https://.firebaseio.com +``` + +``` +sudo chown root:root /etc/node-gestion-api.env +sudo chmod 600 /etc/node-gestion-api.env +``` + +2. En tu servicio systemd: + +``` +EnvironmentFile=/etc/node-gestion-api.env +``` + +Si trabajas localmente, puedes usar `.env` (ya soportado por `dotenv`) y mantenerlo fuera del control de versiones. + +## Script de prueba (FCM) + +El script está en `src/scripts/sendTestNotification.js`. + +Variables esperadas (pueden ir en `.env`): + +- `GOOGLE_APPLICATION_CREDENTIALS` (ruta absoluta) +- `FIREBASE_DATABASE_URL` (opcional) +- `FCM_TEST_TOKEN` +- `FCM_TEST_TITLE` (opcional) +- `FCM_TEST_BODY` (opcional) + +## Uso rápido + +Ejemplo de envío real: + +``` +node src/scripts/sendTestNotification.js --token "" --title "Hola" --body "Prueba" +``` + +Ejemplo de validación sin enviar (dry run): + +``` +node src/scripts/sendTestNotification.js --token "" --dry-run +``` + +Self-test de inicialización (no envía): + +``` +node src/scripts/sendTestNotification.js --self-test +``` + +También puedes usar el script de npm: + +``` +npm run notify:test -- --token "" --dry-run +``` + +## Fotos de estado de viaje (almacenamiento temporal dual) + +Para `POST /api/trips/:id/status`, las fotos pueden guardarse en local y replicarse por SFTP en paralelo. + +- `TRIP_STATUS_UPLOAD_DIR` (opcional): base local. + Default: `uploads/trips/status` dentro del proyecto. +- `TRIP_STATUS_PHOTO_STORAGE_MODE` (opcional): `local` o `dual`. + Default: `local`. +- `TRIP_STATUS_SFTP_HOST`: host SFTP remoto. +- `TRIP_STATUS_SFTP_PORT` (opcional): puerto SFTP. + Default: `22`. +- `TRIP_STATUS_SFTP_USERNAME`: usuario SFTP. +- `TRIP_STATUS_SFTP_PASSWORD`: password SFTP. +- `TRIP_STATUS_SFTP_REMOTE_BASE_DIR`: directorio base remoto. + +Ejemplo de modo temporal dual: + +```bash +TRIP_STATUS_PHOTO_STORAGE_MODE=dual +TRIP_STATUS_SFTP_HOST=194.164.175.51 +TRIP_STATUS_SFTP_PORT=22 +TRIP_STATUS_SFTP_USERNAME=ssh_fotos_estado +TRIP_STATUS_SFTP_PASSWORD=******** +TRIP_STATUS_SFTP_REMOTE_BASE_DIR=/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native +``` + +## API: Crear incidencia de viaje + +Endpoint: `POST /api/trips/:tripId/incidencias` +Auth: `Bearer ` obligatorio. + +### Request JSON + +```json +{ + "incidencia": "Retraso por tráfico", + "notificar": 0, + "notificar_cr": 0 +} +``` + +Notas: +- `incidencia` es obligatoria (string, `trim`, no vacía). +- `notificar`/`notificar_cr` se ignoran; backend fuerza `ind_aviso_cli=1` y `ind_aviso_cr=1`. + +### Responses + +- `201`: + - `{ "success": true, "message": "correcto" }` + - `{ "success": true, "message": "correcto", "warning": "email_failed" }` (si falla SMTP, sin revertir insert) +- `400`: payload inválido (`tripId` inválido o `incidencia` vacía) +- `401`: no autenticado +- `403`: usuario autenticado sin acceso al viaje +- `404`: viaje no existe +- `500`: error interno + +### cURL + +```bash +curl -X POST "http://127.0.0.1:3001/api/trips/248230/incidencias" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "incidencia": "Cliente no localizable en punto de recogida", + "notificar": 0, + "notificar_cr": 0 + }' +``` + +## API: Estado automático de viaje (auditoría) + +Endpoint: `POST /api/trips/:id/auto-status` +Auth: `Bearer ` obligatorio. + +### Request JSON + +```json +{ + "id_estado": 5, + "id_punto": 8123, + "observaciones": "Estado actualizado automáticamente", + "ind_fallido": 0, + "latitud": "40.416775", + "longitud": "-3.703790", + "fecha_y_hora": "2026-02-17 12:34:56" +} +``` + +Notas: +- `id_estado` es obligatorio. +- `id_punto` es opcional. +- Si se envía `id_punto`, `id_estado` solo puede ser `3`, `4` o `5`. +- Sin `id_punto`, se permite cualquier estado válido en `t_viaje_estados`. +- `fecha_y_hora` es opcional: si no es válida se usa hora servidor. +- No se soportan fotos (`fotos_concat`/multipart). +- Este endpoint no actualiza `c_viajes.id_estado`. +- Siempre inserta en `c_cambios_estado` con `actualizado_automaticamente=1`. +- Si hay `id_punto`, también actualiza `c_viajes_puntos` con `actualizado_automaticamente=1` para disparar `trg_c_viajes_puntos_tracking_au`. + +### Responses + +- `200`: estado automático registrado +- `400`: payload inválido +- `401`: no autenticado +- `403`: usuario autenticado sin acceso al viaje +- `404`: viaje o punto no existe +- `422`: `id_punto` enviado con `id_estado` fuera de `3/4/5` +- `500`: error interno + +## Driver license seguro (backend) + +Este backend soporta carga y acceso seguro de carnet de conducir: + +- `POST /api/update_driver_license` (alias: `POST /api/upload_driver_license`) +- No usar `POST /update_profile_photo` para carnet: esa ruta es solo para `foto_perfil` y ahora rechaza payloads de `driver_license`. +- `GET /api/secure/driver-license/side/:side?dni=...&id_proveedor=...` (`side`: `front|back`) +- `GET /api/secure/driver-license/:publicId` (compatibilidad) +- `DELETE /api/secure/driver-license/:publicId` (borrado lógico) + +### Variables de entorno nuevas + +- `DRIVER_LICENSE_ENCRYPTION_KEY` (obligatoria): clave AES-256-GCM de 32 bytes (`hex` de 64 chars o `base64`, opcional prefijo `base64:`). +- `DRIVER_LICENSE_KEY_VERSION` (opcional, default `v1`). +- `DRIVER_LICENSE_STORAGE_DIR` (opcional, default `./secure_storage/driver-license`). +- `DRIVER_LICENSE_RETENTION_DAYS` (opcional, default `365`). +- `DRIVER_LICENSE_PURGE_BATCH_SIZE` (opcional, default `100`). + +### Migración SQL + +Aplicar: + +``` +mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" < src/sql/migrations/20260216_driver_license_security.sql +mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" < src/sql/migrations/20260216_driver_license_sides.sql +``` + +### Purga física por retención + +``` +npm run purge:driver-licenses +``` diff --git a/src/middleware/tripStatusUpload.js b/src/middleware/tripStatusUpload.js index d3182b1..3ff6ced 100644 --- a/src/middleware/tripStatusUpload.js +++ b/src/middleware/tripStatusUpload.js @@ -4,6 +4,7 @@ const multer = require('multer'); const path = require('path'); const { getTripStatusUploadsDir, + getTripStatusFallbackUploadsDir, replicateUploadedFilesToRemote, removeUploadedTripStatusFiles } = require('../services/tripStatusPhotoStorage'); @@ -27,9 +28,21 @@ const getTripDirectorySegment = (req) => { const getTripStatusUploadsTripDir = (req) => path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req)); +const getTripStatusFallbackUploadsTripDir = (req) => + path.join(getTripStatusFallbackUploadsDir(), getTripDirectorySegment(req)); const ensureTripStatusUploadsDir = (req) => { - fs.mkdirSync(getTripStatusUploadsTripDir(req), { recursive: true }); + const primaryTripDir = getTripStatusUploadsTripDir(req); + + try { + fs.mkdirSync(primaryTripDir, { recursive: true }); + return primaryTripDir; + } catch (primaryError) { + const fallbackTripDir = getTripStatusFallbackUploadsTripDir(req); + + fs.mkdirSync(fallbackTripDir, { recursive: true }); + return fallbackTripDir; + } }; const getExtensionFromMimeType = (mimeType) => { @@ -55,8 +68,7 @@ const getExtensionFromMimeType = (mimeType) => { const storage = multer.diskStorage({ destination: (req, file, cb) => { try { - ensureTripStatusUploadsDir(req); - cb(null, getTripStatusUploadsTripDir(req)); + cb(null, ensureTripStatusUploadsDir(req)); } catch (error) { cb(error); } diff --git a/src/middleware/tripStatusUpload_sin_ruta_imag_api.js b/src/middleware/tripStatusUpload_sin_ruta_imag_api.js new file mode 100644 index 0000000..925ad5d --- /dev/null +++ b/src/middleware/tripStatusUpload_sin_ruta_imag_api.js @@ -0,0 +1,153 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const multer = require('multer'); +const path = require('path'); +const { + getTripStatusUploadsDir, + replicateUploadedFilesToRemote, + removeUploadedTripStatusFiles +} = require('../services/tripStatusPhotoStorage'); +const { appendPostLog } = require('../utils/postLog'); + +const MAX_TRIP_STATUS_PHOTO_SIZE_BYTES = 15 * 1024 * 1024; +const MAX_TRIP_STATUS_FILES = 5; +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', + 'image/heic', + 'image/heif' +]); + +const getTripDirectorySegment = (req) => { + const tripId = Number.parseInt(req.params?.id, 10); + return Number.isInteger(tripId) && tripId > 0 ? String(tripId) : 'unknown'; +}; + +const getTripStatusUploadsTripDir = (req) => + path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req)); + +const ensureTripStatusUploadsDir = (req) => { + fs.mkdirSync(getTripStatusUploadsTripDir(req), { recursive: true }); +}; + +const getExtensionFromMimeType = (mimeType) => { + if (mimeType === 'image/png') { + return '.png'; + } + + if (mimeType === 'image/webp') { + return '.webp'; + } + + if (mimeType === 'image/heic') { + return '.heic'; + } + + if (mimeType === 'image/heif') { + return '.heif'; + } + + return '.jpg'; +}; + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + try { + ensureTripStatusUploadsDir(req); + cb(null, getTripStatusUploadsTripDir(req)); + } catch (error) { + cb(error); + } + }, + filename: (req, file, cb) => { + const token = crypto.randomBytes(3).toString('hex'); + const extension = getExtensionFromMimeType(file.mimetype); + cb(null, `${token}${extension}`); + } +}); + +const internalUpload = multer({ + storage, + limits: { + fileSize: MAX_TRIP_STATUS_PHOTO_SIZE_BYTES, + files: MAX_TRIP_STATUS_FILES + }, + fileFilter: (req, file, cb) => { + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + return cb(new Error('INVALID_FILE_TYPE')); + } + + cb(null, true); + } +}); + +const uploadTripStatusPhotos = (req, res, next) => { + internalUpload.fields([ + { name: 'fotos', maxCount: MAX_TRIP_STATUS_FILES }, + { name: 'fotos[]', maxCount: MAX_TRIP_STATUS_FILES } + ])(req, res, async (error) => { + if (!error) { + const uploadedFiles = collectUploadedTripStatusFiles(req); + const authorizationHeader = req.get('authorization'); + + appendPostLog({ + request_id: req.requestId || null, + method: req.method, + path: req.originalUrl || req.url, + ip: req.ip || null, + content_type: req.get('content-type') || null, + has_authorization_header: Boolean(authorizationHeader), + authorization: authorizationHeader ? '[REDACTED]' : null, + query: req.query || {}, + body: req.body || {}, + raw_body: null, + files: uploadedFiles.map((file) => ({ + field_name: file.fieldname || null, + filename: file.filename || null, + originalname: file.originalname || null, + mimetype: file.mimetype || null, + size: Number.isFinite(file.size) ? file.size : null + })) + }); + await replicateUploadedFilesToRemote({ + tripId: req.params?.id, + files: uploadedFiles + }); + return next(); + } + + if (error instanceof multer.MulterError) { + return res.status(400).json({ + success: false, + error: 'Invalid payload' + }); + } + + if (error.message === 'INVALID_FILE_TYPE') { + return res.status(400).json({ + success: false, + error: 'Invalid payload' + }); + } + + console.error('Error uploading trip status photos:', error); + return res.status(500).json({ + success: false, + error: 'Internal server error' + }); + }); +}; + +const collectUploadedTripStatusFiles = (req) => [ + ...(Array.isArray(req.files?.fotos) ? req.files.fotos : []), + ...(Array.isArray(req.files?.['fotos[]']) ? req.files['fotos[]'] : []) +]; + +module.exports = { + uploadTripStatusPhotos, + collectUploadedTripStatusFiles, + removeUploadedTripStatusFiles, + MAX_TRIP_STATUS_FILES +}; \ No newline at end of file diff --git a/src/services/tripStatusPhotoStorage.js b/src/services/tripStatusPhotoStorage.js index 74617b0..d6b6131 100644 --- a/src/services/tripStatusPhotoStorage.js +++ b/src/services/tripStatusPhotoStorage.js @@ -6,10 +6,47 @@ const DEFAULT_SFTP_PORT = 22; let sftpClientFactoryOverride = null; -const getTripStatusUploadsDir = () => - process.env.TRIP_STATUS_UPLOAD_DIR || +const PRIMARY_TRIP_STATUS_UPLOAD_DIR = + '/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native/trips/status'; +const FALLBACK_TRIP_STATUS_UPLOAD_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status'); +const resolveUploadDirCandidate = (uploadDir) => + path.isAbsolute(uploadDir) + ? uploadDir + : path.resolve(__dirname, '..', '..', uploadDir); + +const getExistingAncestorScore = (targetPath) => { + let currentPath = targetPath; + let score = 0; + + while (currentPath && currentPath !== path.dirname(currentPath)) { + if (fs.existsSync(currentPath)) { + return score; + } + + currentPath = path.dirname(currentPath); + score += 1; + } + + return Number.MAX_SAFE_INTEGER; +}; + +const selectUploadDirCandidate = (uploadDirs) => + uploadDirs + .map((uploadDir, index) => ({ + index, + path: resolveUploadDirCandidate(uploadDir), + score: getExistingAncestorScore(resolveUploadDirCandidate(uploadDir)) + })) + .sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path; + +const getTripStatusUploadsDir = () => { + return PRIMARY_TRIP_STATUS_UPLOAD_DIR; +}; + +const getTripStatusFallbackUploadsDir = () => FALLBACK_TRIP_STATUS_UPLOAD_DIR; + const getTripStatusPhotoStorageMode = () => String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local') .trim() @@ -352,6 +389,7 @@ const __resetSftpClientFactoryForTests = () => { module.exports = { getTripStatusUploadsDir, + getTripStatusFallbackUploadsDir, replicateUploadedFilesToRemote, removeUploadedTripStatusFiles, removeStatusPhotosByName, diff --git a/src/services/tripStatusPhotoStorage_sin_ruta_mod_imag_api.js b/src/services/tripStatusPhotoStorage_sin_ruta_mod_imag_api.js new file mode 100644 index 0000000..74617b0 --- /dev/null +++ b/src/services/tripStatusPhotoStorage_sin_ruta_mod_imag_api.js @@ -0,0 +1,360 @@ +const fs = require('fs'); +const path = require('path'); + +const REMOTE_STORAGE_MODES = new Set(['dual', 'sftp']); +const DEFAULT_SFTP_PORT = 22; + +let sftpClientFactoryOverride = null; + +const getTripStatusUploadsDir = () => + process.env.TRIP_STATUS_UPLOAD_DIR || + path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status'); + +const getTripStatusPhotoStorageMode = () => + String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local') + .trim() + .toLowerCase(); + +const shouldUseRemoteStorage = () => + REMOTE_STORAGE_MODES.has(getTripStatusPhotoStorageMode()); + +const getTripDirectorySegment = (tripId) => { + const parsedTripId = Number.parseInt(tripId, 10); + return Number.isInteger(parsedTripId) && parsedTripId > 0 ? String(parsedTripId) : 'unknown'; +}; + +const normalizeRemoteBaseDir = (rawValue) => + String(rawValue || '') + .trim() + .replace(/\/+$/g, ''); + +const getSftpConfig = () => { + const host = String(process.env.TRIP_STATUS_SFTP_HOST || '').trim(); + const username = String(process.env.TRIP_STATUS_SFTP_USERNAME || '').trim(); + const password = String(process.env.TRIP_STATUS_SFTP_PASSWORD || '').trim(); + const remoteBaseDir = normalizeRemoteBaseDir(process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR); + const parsedPort = Number.parseInt(process.env.TRIP_STATUS_SFTP_PORT, 10); + const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : DEFAULT_SFTP_PORT; + const missing = []; + + if (!host) { + missing.push('TRIP_STATUS_SFTP_HOST'); + } + if (!username) { + missing.push('TRIP_STATUS_SFTP_USERNAME'); + } + if (!password) { + missing.push('TRIP_STATUS_SFTP_PASSWORD'); + } + if (!remoteBaseDir) { + missing.push('TRIP_STATUS_SFTP_REMOTE_BASE_DIR'); + } + + return { + host, + username, + password, + port, + remoteBaseDir, + isValid: missing.length === 0, + missing + }; +}; + +const getSftpClientFactory = () => { + if (typeof sftpClientFactoryOverride === 'function') { + return sftpClientFactoryOverride; + } + + try { + const SftpClient = require('ssh2-sftp-client'); + return () => new SftpClient(); + } catch (error) { + console.error('SFTP client dependency is unavailable for trip status photos:', { + message: error.message + }); + return null; + } +}; + +const isRemoteFileNotFoundError = (error) => { + const normalizedMessage = String(error?.message || '').toLowerCase(); + return ( + error?.code === 'ENOENT' || + error?.code === 2 || + normalizedMessage.includes('no such file') || + normalizedMessage.includes('not exist') + ); +}; + +const buildRemoteTripDir = (remoteBaseDir, tripId) => + path.posix.join(remoteBaseDir, getTripDirectorySegment(tripId)); + +const buildRemoteFilePath = (remoteBaseDir, tripId, fileName) => + path.posix.join(buildRemoteTripDir(remoteBaseDir, tripId), fileName); + +const ensureRemoteTripDirectory = async (client, { remoteBaseDir, tripDirectorySegment }) => { + const remoteTripDir = buildRemoteTripDir(remoteBaseDir, tripDirectorySegment); + + const baseExists = await client.exists(remoteBaseDir); + if (!baseExists) { + await client.mkdir(remoteBaseDir, true); + } + + const tripDirExists = await client.exists(remoteTripDir); + if (!tripDirExists) { + await client.mkdir(remoteTripDir, false); + } + + return remoteTripDir; +}; + +const withSftpClient = async (handler, { logContext }) => { + if (!shouldUseRemoteStorage()) { + return false; + } + + const sftpConfig = getSftpConfig(); + if (!sftpConfig.isValid) { + console.error('Trip status photo remote storage skipped due to missing SFTP config:', { + context: logContext, + missing: sftpConfig.missing + }); + return false; + } + + const createClient = getSftpClientFactory(); + if (!createClient) { + return false; + } + + const client = createClient(); + try { + await client.connect({ + host: sftpConfig.host, + port: sftpConfig.port, + username: sftpConfig.username, + password: sftpConfig.password + }); + + await handler(client, sftpConfig); + return true; + } catch (error) { + console.error('Trip status photo remote storage operation failed:', { + context: logContext, + message: error.message + }); + return false; + } finally { + try { + await client.end(); + } catch (closeError) { + console.error('Failed to close SFTP connection for trip status photos:', { + context: logContext, + message: closeError.message + }); + } + } +}; + +const replicateUploadedFilesToRemote = async ({ tripId, files }) => { + const normalizedFiles = (files || []).filter((file) => file?.path && file?.filename); + if (normalizedFiles.length === 0) { + return; + } + + const tripDirectorySegment = getTripDirectorySegment(tripId); + for (const file of normalizedFiles) { + file.tripStatusTripId = tripDirectorySegment; + } + + await withSftpClient( + async (client, sftpConfig) => { + await ensureRemoteTripDirectory(client, { + remoteBaseDir: sftpConfig.remoteBaseDir, + tripDirectorySegment + }); + + for (const file of normalizedFiles) { + const remoteFilePath = buildRemoteFilePath( + sftpConfig.remoteBaseDir, + tripDirectorySegment, + file.filename + ); + + try { + await client.put(file.path, remoteFilePath); + file.tripStatusRemoteUploaded = true; + file.tripStatusRemotePath = remoteFilePath; + } catch (error) { + file.tripStatusRemoteUploaded = false; + file.tripStatusRemotePath = null; + console.error('Failed to replicate trip status photo to SFTP. Local fallback kept:', { + tripId: tripDirectorySegment, + fileName: file.filename, + message: error.message + }); + } + } + }, + { + logContext: 'replicate_upload' + } + ); +}; + +const removeRemoteFiles = async (remoteFilePaths, { logContext }) => { + const uniqueRemotePaths = Array.from(new Set((remoteFilePaths || []).filter(Boolean))); + if (uniqueRemotePaths.length === 0) { + return; + } + + await withSftpClient( + async (client) => { + for (const remoteFilePath of uniqueRemotePaths) { + try { + await client.delete(remoteFilePath); + } catch (error) { + if (isRemoteFileNotFoundError(error)) { + continue; + } + + console.error('Failed to remove remote trip status photo:', { + context: logContext, + remoteFilePath, + message: error.message + }); + } + } + }, + { logContext } + ); +}; + +const removeLocalFileIfExists = async (filePath, { logContext, fileName, tripId }) => { + try { + await fs.promises.unlink(filePath); + return true; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + console.error('Failed to remove local trip status photo:', { + context: logContext, + tripId, + file: fileName, + path: filePath, + message: error.message + }); + return false; + } +}; + +const cleanupUploadedTripStatusFiles = async (files) => { + const normalizedFiles = (files || []).filter((file) => file?.path || file?.filename); + if (normalizedFiles.length === 0) { + return; + } + + const sftpConfig = getSftpConfig(); + const remoteFilePaths = []; + for (const file of normalizedFiles) { + if (file?.tripStatusRemotePath) { + remoteFilePaths.push(file.tripStatusRemotePath); + continue; + } + + const tripId = file?.tripStatusTripId; + if (shouldUseRemoteStorage() && sftpConfig.isValid && tripId && file?.filename) { + remoteFilePaths.push( + buildRemoteFilePath(sftpConfig.remoteBaseDir, tripId, file.filename) + ); + } + } + + await removeRemoteFiles(remoteFilePaths, { logContext: 'cleanup_uploaded_files' }); + + for (const file of normalizedFiles) { + if (!file?.path) { + continue; + } + + await removeLocalFileIfExists(file.path, { + logContext: 'cleanup_uploaded_files', + fileName: file.filename || null, + tripId: file?.tripStatusTripId || null + }); + } +}; + +const parsePhotoNames = (rawPhotoReferences) => + String(rawPhotoReferences || '') + .split(';') + .map((item) => item.trim()) + .filter(Boolean) + .filter((item) => !item.includes('/') && !item.includes('\\')); + +const removeStatusPhotosByName = async ({ tripId, rawPhotoReferences }) => { + const photoNames = parsePhotoNames(rawPhotoReferences); + if (photoNames.length === 0) { + return; + } + + const uploadsDir = getTripStatusUploadsDir(); + const tripUploadsDir = path.join(uploadsDir, getTripDirectorySegment(tripId)); + + const sftpConfig = getSftpConfig(); + const remoteFilePaths = + shouldUseRemoteStorage() && sftpConfig.isValid + ? photoNames.map((photoName) => + buildRemoteFilePath(sftpConfig.remoteBaseDir, tripId, photoName) + ) + : []; + + await removeRemoteFiles(remoteFilePaths, { logContext: 'remove_status_photos_by_name' }); + + for (const photoName of photoNames) { + const candidatePaths = [ + path.join(tripUploadsDir, photoName), + path.join(uploadsDir, photoName) + ]; + + for (const candidatePath of candidatePaths) { + const removed = await removeLocalFileIfExists(candidatePath, { + logContext: 'remove_status_photos_by_name', + fileName: photoName, + tripId + }); + + if (removed) { + break; + } + } + } +}; + +const removeUploadedTripStatusFiles = (files) => { + cleanupUploadedTripStatusFiles(files).catch((error) => { + console.error('Failed to cleanup uploaded trip status files:', { + message: error.message + }); + }); +}; + +const __setSftpClientFactoryForTests = (factory) => { + sftpClientFactoryOverride = typeof factory === 'function' ? factory : null; +}; + +const __resetSftpClientFactoryForTests = () => { + sftpClientFactoryOverride = null; +}; + +module.exports = { + getTripStatusUploadsDir, + replicateUploadedFilesToRemote, + removeUploadedTripStatusFiles, + removeStatusPhotosByName, + __setSftpClientFactoryForTests, + __resetSftpClientFactoryForTests +};