falla desde api
This commit is contained in:
parent
e56ff9e2c8
commit
5a5ba850ba
33
.env_sin_ruta_imagenes_api
Normal file
33
.env_sin_ruta_imagenes_api
Normal file
@ -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
|
||||||
@ -81,7 +81,7 @@ npm run notify:test -- --token "<FCM_DEVICE_TOKEN>" --dry-run
|
|||||||
|
|
||||||
Para `POST /api/trips/:id/status`, las fotos pueden guardarse en local y replicarse por SFTP en paralelo.
|
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.
|
Default: `uploads/trips/status` dentro del proyecto.
|
||||||
- `TRIP_STATUS_PHOTO_STORAGE_MODE` (opcional): `local` o `dual`.
|
- `TRIP_STATUS_PHOTO_STORAGE_MODE` (opcional): `local` o `dual`.
|
||||||
Default: `local`.
|
Default: `local`.
|
||||||
|
|||||||
220
README_sin_ruta_mod_imagenes_api.md
Normal file
220
README_sin_ruta_mod_imagenes_api.md
Normal file
@ -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://<DATABASE_NAME>.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 "<FCM_DEVICE_TOKEN>" --title "Hola" --body "Prueba"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo de validación sin enviar (dry run):
|
||||||
|
|
||||||
|
```
|
||||||
|
node src/scripts/sendTestNotification.js --token "<FCM_DEVICE_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 "<FCM_DEVICE_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 <JWT>` 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 <JWT>" \
|
||||||
|
-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 <JWT>` 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
|
||||||
|
```
|
||||||
@ -4,6 +4,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const {
|
const {
|
||||||
getTripStatusUploadsDir,
|
getTripStatusUploadsDir,
|
||||||
|
getTripStatusFallbackUploadsDir,
|
||||||
replicateUploadedFilesToRemote,
|
replicateUploadedFilesToRemote,
|
||||||
removeUploadedTripStatusFiles
|
removeUploadedTripStatusFiles
|
||||||
} = require('../services/tripStatusPhotoStorage');
|
} = require('../services/tripStatusPhotoStorage');
|
||||||
@ -27,9 +28,21 @@ const getTripDirectorySegment = (req) => {
|
|||||||
|
|
||||||
const getTripStatusUploadsTripDir = (req) =>
|
const getTripStatusUploadsTripDir = (req) =>
|
||||||
path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req));
|
path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req));
|
||||||
|
const getTripStatusFallbackUploadsTripDir = (req) =>
|
||||||
|
path.join(getTripStatusFallbackUploadsDir(), getTripDirectorySegment(req));
|
||||||
|
|
||||||
const ensureTripStatusUploadsDir = (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) => {
|
const getExtensionFromMimeType = (mimeType) => {
|
||||||
@ -55,8 +68,7 @@ const getExtensionFromMimeType = (mimeType) => {
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
try {
|
try {
|
||||||
ensureTripStatusUploadsDir(req);
|
cb(null, ensureTripStatusUploadsDir(req));
|
||||||
cb(null, getTripStatusUploadsTripDir(req));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cb(error);
|
cb(error);
|
||||||
}
|
}
|
||||||
|
|||||||
153
src/middleware/tripStatusUpload_sin_ruta_imag_api.js
Normal file
153
src/middleware/tripStatusUpload_sin_ruta_imag_api.js
Normal file
@ -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
|
||||||
|
};
|
||||||
@ -6,10 +6,47 @@ const DEFAULT_SFTP_PORT = 22;
|
|||||||
|
|
||||||
let sftpClientFactoryOverride = null;
|
let sftpClientFactoryOverride = null;
|
||||||
|
|
||||||
const getTripStatusUploadsDir = () =>
|
const PRIMARY_TRIP_STATUS_UPLOAD_DIR =
|
||||||
process.env.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');
|
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 = () =>
|
const getTripStatusPhotoStorageMode = () =>
|
||||||
String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
|
String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
|
||||||
.trim()
|
.trim()
|
||||||
@ -352,6 +389,7 @@ const __resetSftpClientFactoryForTests = () => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getTripStatusUploadsDir,
|
getTripStatusUploadsDir,
|
||||||
|
getTripStatusFallbackUploadsDir,
|
||||||
replicateUploadedFilesToRemote,
|
replicateUploadedFilesToRemote,
|
||||||
removeUploadedTripStatusFiles,
|
removeUploadedTripStatusFiles,
|
||||||
removeStatusPhotosByName,
|
removeStatusPhotosByName,
|
||||||
|
|||||||
360
src/services/tripStatusPhotoStorage_sin_ruta_mod_imag_api.js
Normal file
360
src/services/tripStatusPhotoStorage_sin_ruta_mod_imag_api.js
Normal file
@ -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
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user