chore: sincronizar cambios pendientes de desarrollo a produccion

This commit is contained in:
2026-09-30 11:48:32 +02:00
parent dc545c9d86
commit c2dbe773a4
7 changed files with 609 additions and 10 deletions
+276
View File
@@ -0,0 +1,276 @@
# Manual de despliegue a produccion: paginacion de GET /api/trips
## Alcance
Este despliegue publica el cambio del backend para que `GET /api/trips` aplique paginacion y filtros en SQL.
Incluye:
- Cambios en `src/controllers/tripsController.js`.
- Tests de integracion en `test/trips.list.integration.test.js`.
- Documentacion en `README.md`.
- Migracion de indices: `src/sql/migrations/20260630_trips_list_pagination_indexes.sql`.
No incluye cambios en la app React Native.
## Supuestos
- El servidor Node de produccion usa este proyecto: `/var/www/node.gestion.abianservice.com`.
- La API escucha en `127.0.0.1:3001` salvo que se sobreescriba `PORT`.
- El restart operativo se hace con `src/scripts/restartNodeApi.sh`.
- Las variables `DB_HOST`, `DB_USER`, `DB_PASSWORD` y `DB_NAME` de produccion estan configuradas en `.env` o disponibles en shell.
- Las migraciones SQL se aplican manualmente con `mysql`.
Si alguno de estos puntos no coincide con produccion, parar y ajustar el manual antes de ejecutar.
## Criterios de exito
- `GET /api/trips` sin parametros responde `200` con maximo 25 viajes por defecto.
- La respuesta incluye `trips`, `page`, `limit`, `total` y `has_more`.
- Las llamadas con `page`, `limit`, `status_ids`, `date_from` y `date_to` filtran en base de datos.
- La API arranca correctamente y `/health` responde `200`.
- `/api/trips/active` sin token responde `401`.
- No aparecen errores nuevos en el log de la API.
## 1. Precheck antes de tocar produccion
Desde la raiz del proyecto:
```bash
cd /var/www/node.gestion.abianservice.com
git status --short
node --version
npm --version
```
Revisar que no haya cambios locales no esperados. Si hay cambios ajenos, no sobrescribirlos.
Ejecutar tests antes de desplegar:
```bash
npm test
```
Tambien se puede ejecutar solo la cobertura del endpoint:
```bash
node --test test/trips.list.integration.test.js
```
## 2. Backup rapido de base de datos
Antes de aplicar indices, sacar al menos backup de las tablas tocadas por la migracion:
```bash
mkdir -p /tmp/abian-db-backups
mysqldump -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
c_viajes c_viajes_proveedor c_cambios_estado \
> /tmp/abian-db-backups/trips_indexes_$(date +%Y%m%d_%H%M%S).sql
```
Si `DB_PASSWORD` no esta exportada en shell, cargar `.env` de forma controlada o introducir la password manualmente con `-p`.
## 3. Aplicar codigo
Actualizar el codigo de produccion con el mecanismo habitual del equipo. Si se usa Git en el servidor:
```bash
cd /var/www/node.gestion.abianservice.com
git fetch --all --prune
git status --short
```
Antes de cambiar de revision, confirmar que `git status --short` no muestra cambios locales que no deban pisarse.
Despues de actualizar la rama o revision objetivo, instalar dependencias solo si `package-lock.json` cambio:
```bash
npm ci --omit=dev
```
Para este cambio no deberia ser necesario si solo se despliega la paginacion de viajes.
## 4. Aplicar migracion de indices
La migracion es idempotente: comprueba `information_schema.statistics` antes de crear cada indice.
```bash
cd /var/www/node.gestion.abianservice.com
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
< src/sql/migrations/20260630_trips_list_pagination_indexes.sql
```
Validar que los indices existen:
```bash
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e "
SHOW INDEX FROM c_viajes WHERE Key_name = 'idx_c_viajes_id_estado';
SHOW INDEX FROM c_viajes_proveedor WHERE Key_name IN (
'idx_c_viajes_proveedor_fecha_salida',
'idx_c_viajes_proveedor_fecha_llegada',
'idx_c_viajes_proveedor_dni_estado_dates'
);
SHOW INDEX FROM c_cambios_estado WHERE Key_name = 'idx_c_cambios_estado_viaje_estado_fecha';
"
```
Si la tabla es grande, esta fase puede tardar. No reiniciar la API hasta que termine correctamente.
## 5. Reiniciar API
Usar el script incluido en el repo:
```bash
cd /var/www/node.gestion.abianservice.com
bash src/scripts/restartNodeApi.sh
```
El script valida:
- Proceso Node arrancado.
- Puerto `127.0.0.1:3001` escuchando.
- `/health` responde `200`.
- `/api/trips/active` sin token responde `401`.
Si produccion usa otro puerto o ruta, exportar variables antes de ejecutar:
```bash
PORT=3001 HOST=127.0.0.1 bash src/scripts/restartNodeApi.sh
```
## 6. Smoke tests post-deploy
### Sin token
Debe devolver `401`:
```bash
curl -i "http://127.0.0.1:3001/api/trips"
```
### Con token real de conductor
Usar un JWT valido de la app o de una sesion de pruebas.
```bash
TOKEN="<JWT_VALIDO>"
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25" \
-H "Authorization: Bearer $TOKEN"
```
Comprobar que la respuesta contiene:
```json
{
"trips": [],
"page": 1,
"limit": 25,
"total": 0,
"has_more": false
}
```
`trips` puede traer datos reales, pero `page`, `limit`, `total` y `has_more` deben existir.
Probar historico:
```bash
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25&status_ids=7,8,9" \
-H "Authorization: Bearer $TOKEN"
```
Probar proximos desde una fecha:
```bash
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25&date_from=2026-06-30" \
-H "Authorization: Bearer $TOKEN"
```
Probar rango de fechas:
```bash
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25&date_from=2026-06-01&date_to=2026-06-30" \
-H "Authorization: Bearer $TOKEN"
```
### Validaciones negativas
Deben devolver `400`:
```bash
curl -i "http://127.0.0.1:3001/api/trips?page=0" -H "Authorization: Bearer $TOKEN"
curl -i "http://127.0.0.1:3001/api/trips?limit=101" -H "Authorization: Bearer $TOKEN"
curl -i "http://127.0.0.1:3001/api/trips?status_ids=7,x" -H "Authorization: Bearer $TOKEN"
curl -i "http://127.0.0.1:3001/api/trips?date_from=2026-02-30" -H "Authorization: Bearer $TOKEN"
```
## 7. Logs a revisar
Log por defecto del script de restart:
```bash
tail -n 100 /tmp/node-gestion-api.log
```
Buscar entradas del listado:
```bash
grep "\[TripsList\]" /tmp/node-gestion-api.log | tail -n 20
```
Cada entrada debe mostrar `page`, `limit`, `rows`, `total` y `elapsed_ms`.
Senales de problema:
- `Error getting trips list`.
- `ER_PARSE_ERROR`.
- `ER_BAD_FIELD_ERROR`.
- Tiempos `elapsed_ms` altos de forma sostenida.
- Respuestas sin `page`, `limit`, `total` o `has_more`.
## 8. Rollback
### Rollback de codigo
Volver a la revision anterior con el mecanismo habitual del equipo y reiniciar:
```bash
cd /var/www/node.gestion.abianservice.com
bash src/scripts/restartNodeApi.sh
```
### Rollback de indices
Normalmente no hace falta retirar indices: son compatibles y no cambian datos. Solo retirarlos si se confirma impacto operativo.
Comandos de retirada, si fueran necesarios:
```sql
ALTER TABLE c_viajes DROP INDEX idx_c_viajes_id_estado;
ALTER TABLE c_viajes_proveedor DROP INDEX idx_c_viajes_proveedor_fecha_salida;
ALTER TABLE c_viajes_proveedor DROP INDEX idx_c_viajes_proveedor_fecha_llegada;
ALTER TABLE c_viajes_proveedor DROP INDEX idx_c_viajes_proveedor_dni_estado_dates;
ALTER TABLE c_cambios_estado DROP INDEX idx_c_cambios_estado_viaje_estado_fecha;
```
Ejecutarlos solo tras confirmar que esos indices no existian antes del despliegue.
## 9. Checklist final
- [ ] Tests ejecutados antes del despliegue.
- [ ] Backup de tablas creado.
- [ ] Codigo actualizado.
- [ ] Migracion de indices aplicada sin errores.
- [ ] API reiniciada con `restartNodeApi.sh`.
- [ ] `/health` responde `200`.
- [ ] `/api/trips/active` sin token responde `401`.
- [ ] `GET /api/trips?page=1&limit=25` con token responde metadata de paginacion.
- [ ] Filtros `status_ids`, `date_from` y `date_to` verificados.
- [ ] Logs sin errores nuevos.
+23
View File
@@ -77,6 +77,15 @@ También puedes usar el script de npm:
npm run notify:test -- --token "<FCM_DEVICE_TOKEN>" --dry-run npm run notify:test -- --token "<FCM_DEVICE_TOKEN>" --dry-run
``` ```
## API: Listado de viajes
Endpoint: `GET /api/trips`
Auth: `Bearer <JWT>` obligatorio.
Parametros opcionales: `page` (default `1`), `limit` (default `25`, maximo `100`), `status_ids` separado por comas, `date_from` y `date_to` en formato `YYYY-MM-DD`.
La respuesta mantiene `trips` con los campos legacy de cada viaje y añade `page`, `limit`, `total` y `has_more`. La paginacion y filtros se aplican en SQL. El orden estable del listado es `COALESCE(p.fecha_salida, v.fecha_salida) DESC, p.id_viaje DESC`.
## Fotos de estado de viaje (almacenamiento temporal dual) ## 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. Para `POST /api/trips/:id/status`, las fotos pueden guardarse en local y replicarse por SFTP en paralelo.
@@ -103,6 +112,20 @@ TRIP_STATUS_SFTP_PASSWORD=********
TRIP_STATUS_SFTP_REMOTE_BASE_DIR=/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native TRIP_STATUS_SFTP_REMOTE_BASE_DIR=/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native
``` ```
## Diagnostico de subidas desde la app
Las peticiones `POST` multipart dejan eventos JSON en `POSTS_LOG_PATH` (por defecto,
`/var/log/posts.log`):
- `upload_request_started`: la API recibio la peticion.
- `upload_request_finished`: incluye estado HTTP, duracion, fase del posible fallo y
resultado del parser multipart.
- `upload_request_interrupted`: el cliente o la conexion se cerro antes de terminar.
Todos los eventos de una peticion comparten `request_id`, que tambien se devuelve en
la cabecera `x-request-id`. No se registran el token, los valores del formulario, el
contenido del archivo ni su nombre original.
## API: Crear incidencia de viaje ## API: Crear incidencia de viaje
Endpoint: `POST /api/trips/:tripId/incidencias` Endpoint: `POST /api/trips/:tripId/incidencias`
+1 -1
View File
@@ -8,7 +8,7 @@ NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
HOST="${HOST:-127.0.0.1}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-3001}" PORT="${PORT:-3001}"
LOG_FILE="${LOG_FILE:-/tmp/node-gestion-api.log}" LOG_FILE="${LOG_FILE:-/tmp/node-gestion-api.log}"
HEALTH_PATH="${HEALTH_PATH:-/}" HEALTH_PATH="${HEALTH_PATH:-/health}"
AUTH_CHECK_PATH="${AUTH_CHECK_PATH:-/api/trips/active}" AUTH_CHECK_PATH="${AUTH_CHECK_PATH:-/api/trips/active}"
AUTH_EXPECTED_STATUS="${AUTH_EXPECTED_STATUS:-401}" AUTH_EXPECTED_STATUS="${AUTH_EXPECTED_STATUS:-401}"
STARTUP_TIMEOUT_SEC="${STARTUP_TIMEOUT_SEC:-30}" STARTUP_TIMEOUT_SEC="${STARTUP_TIMEOUT_SEC:-30}"
@@ -0,0 +1,64 @@
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'c_viajes'
AND index_name = 'idx_c_viajes_id_estado') = 0,
'ALTER TABLE c_viajes ADD KEY idx_c_viajes_id_estado (id_estado)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'c_viajes_proveedor'
AND index_name = 'idx_c_viajes_proveedor_fecha_salida') = 0,
'ALTER TABLE c_viajes_proveedor ADD KEY idx_c_viajes_proveedor_fecha_salida (fecha_salida)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'c_viajes_proveedor'
AND index_name = 'idx_c_viajes_proveedor_fecha_llegada') = 0,
'ALTER TABLE c_viajes_proveedor ADD KEY idx_c_viajes_proveedor_fecha_llegada (fecha_llegada)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'c_viajes_proveedor'
AND index_name = 'idx_c_viajes_proveedor_dni_estado_dates') = 0,
'ALTER TABLE c_viajes_proveedor ADD KEY idx_c_viajes_proveedor_dni_estado_dates (dni, id_viaje, fecha_salida, fecha_llegada)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'c_cambios_estado'
AND index_name = 'idx_c_cambios_estado_viaje_estado_fecha') = 0,
'ALTER TABLE c_cambios_estado ADD KEY idx_c_cambios_estado_viaje_estado_fecha (id_viaje, id_estado, fecha_y_hora)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+9 -2
View File
@@ -108,6 +108,7 @@ test('GET /api/trips/active devuelve viaje activo', async () => {
cod_viaje: 'AB-2024-001', cod_viaje: 'AB-2024-001',
n_proveedor: 1, n_proveedor: 1,
id_estado: 4, id_estado: 4,
statusId: 4,
estado: 'CARGA DE MERCANCÍA', estado: 'CARGA DE MERCANCÍA',
estado_en: 'CARGO LOADING', estado_en: 'CARGO LOADING',
nombrea: 'ORIGEN', nombrea: 'ORIGEN',
@@ -122,13 +123,19 @@ test('GET /api/trips/active devuelve viaje activo', async () => {
}; };
db.query = async (sql, params) => { db.query = async (sql, params) => {
assert.match(sql, /FROM c_viajes_proveedor p/); if (/FROM c_viajes_proveedor p/.test(sql)) {
assert.match(sql, /INNER JOIN t_viaje_estados e/); assert.match(sql, /INNER JOIN t_viaje_estados e/);
assert.match(sql, /fecha_salida/); assert.match(sql, /fecha_salida/);
assert.match(sql, /fecha_llegada/); assert.match(sql, /fecha_llegada/);
assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/); assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/);
assert.deepEqual(params, ['58045340X', 2, 6]); assert.deepEqual(params, ['58045340X', 2, 6, '58045340X', 1]);
return [[mockedTrip]]; return [[mockedTrip]];
}
assert.match(sql, /FROM c_cambios_estado c/);
assert.match(sql, /FROM c_viajes_puntos vp/);
assert.deepEqual(params, [123, 123, 3, 4, 5]);
return [[]];
}; };
const response = await withServer(async (server) => const response = await withServer(async (server) =>
+10 -2
View File
@@ -540,9 +540,17 @@ test('POST /api/trips/:id/status multipart registra body parseado y ficheros en
); );
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
await waitFor(() => fs.existsSync(TEST_POSTS_LOG_PATH)); await waitFor(() =>
fs.existsSync(TEST_POSTS_LOG_PATH) &&
readJsonLines(TEST_POSTS_LOG_PATH).some(
(logEntry) => logEntry.body?.id_estado === '5'
)
);
const [entry] = readJsonLines(TEST_POSTS_LOG_PATH); const entry = readJsonLines(TEST_POSTS_LOG_PATH).find(
(logEntry) => logEntry.body?.id_estado === '5'
);
assert.ok(entry, 'Expected parsed multipart payload log entry');
assert.equal(entry.method, 'POST'); assert.equal(entry.method, 'POST');
assert.equal(entry.path, '/api/trips/248230/status'); assert.equal(entry.path, '/api/trips/248230/status');
assert.equal(entry.body.id_estado, '5'); assert.equal(entry.body.id_estado, '5');
+221
View File
@@ -0,0 +1,221 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const TEST_LOG_PATH = path.resolve(__dirname, '..', 'tmp', 'test-upload-diagnostics.log');
process.env.POSTS_LOG_PATH = TEST_LOG_PATH;
const app = require('../app');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
const createToken = () =>
jwt.sign(
{
id: 1,
dni: '58045340X',
id_proveedor: 675
},
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 requestMultipart = async ({ port, authorization, fields = {}, file }) =>
new Promise((resolve, reject) => {
const boundary = `----UploadDiagnostics${Date.now().toString(16)}`;
const chunks = [];
for (const [key, value] of Object.entries(fields)) {
chunks.push(Buffer.from(`--${boundary}\r\n`));
chunks.push(
Buffer.from(`Content-Disposition: form-data; name="${key}"\r\n\r\n${String(value)}\r\n`)
);
}
if (file) {
chunks.push(Buffer.from(`--${boundary}\r\n`));
chunks.push(
Buffer.from(
`Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.filename}"\r\n` +
`Content-Type: ${file.contentType}\r\n\r\n`
)
);
chunks.push(file.content);
chunks.push(Buffer.from('\r\n'));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
const bodyBuffer = Buffer.concat(chunks);
const request = http.request(
{
hostname: '127.0.0.1',
port,
method: 'POST',
path: '/update_profile_photo',
headers: {
...(authorization ? { authorization } : {}),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': bodyBuffer.length
}
},
(response) => {
let responseBody = '';
response.on('data', (chunk) => {
responseBody += chunk;
});
response.on('end', () => {
resolve({
statusCode: response.statusCode,
headers: response.headers,
body: responseBody ? JSON.parse(responseBody) : null
});
});
}
);
request.on('error', reject);
request.end(bodyBuffer);
});
const readLogEntries = () => {
if (!fs.existsSync(TEST_LOG_PATH)) {
return [];
}
return fs
.readFileSync(TEST_LOG_PATH, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
};
const waitForFinishedLog = async () => {
const startedAt = Date.now();
while (Date.now() - startedAt < 1500) {
const finished = readLogEntries().find(
(entry) => entry.event === 'upload_request_finished'
);
if (finished) {
return finished;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('Timed out waiting for upload diagnostics log');
};
test.beforeEach(() => {
fs.rmSync(TEST_LOG_PATH, { force: true });
});
test.after(() => {
delete process.env.POSTS_LOG_PATH;
fs.rmSync(TEST_LOG_PATH, { force: true });
});
test('multipart sin autenticacion identifica fallo antes del parser', async () => {
const response = await withServer((server) =>
requestMultipart({
port: server.address().port,
file: {
fieldName: 'foto_perfil',
filename: 'profile.jpg',
contentType: 'image/jpeg',
content: Buffer.from('jpeg')
}
})
);
assert.equal(response.statusCode, 403);
const finished = await waitForFinishedLog();
const started = readLogEntries().find(
(entry) => entry.event === 'upload_request_started'
);
assert.equal(finished.request_id, response.headers['x-request-id']);
assert.equal(started.request_id, finished.request_id);
assert.equal(finished.failure_stage, 'authentication_rate_limit_or_route');
assert.equal(finished.upload.parser_status, 'not_reached');
});
test('tipo de archivo invalido identifica rechazo del parser multipart', async () => {
const token = createToken();
const response = await withServer((server) =>
requestMultipart({
port: server.address().port,
authorization: `Bearer ${token}`,
file: {
fieldName: 'foto_perfil',
filename: 'profile.txt',
contentType: 'text/plain',
content: Buffer.from('not-an-image')
}
})
);
assert.equal(response.statusCode, 400);
const finished = await waitForFinishedLog();
assert.equal(finished.failure_stage, 'multipart_parser');
assert.equal(finished.upload.flow, 'profile_photo');
assert.equal(finished.upload.parser_status, 'rejected');
assert.equal(finished.upload.error.code, 'INVALID_FILE_TYPE');
assert.equal(JSON.stringify(readLogEntries()).includes(token), false);
assert.equal(JSON.stringify(readLogEntries()).includes('profile.txt'), false);
});
test('payload invalido tras guardar el archivo identifica fallo posterior al parser', async () => {
const response = await withServer((server) =>
requestMultipart({
port: server.address().port,
authorization: `Bearer ${createToken()}`,
fields: {
id_proveedor: 675
},
file: {
fieldName: 'foto_perfil',
filename: 'profile.jpg',
contentType: 'image/jpeg',
content: Buffer.from('jpeg')
}
})
);
assert.equal(response.statusCode, 400);
const finished = await waitForFinishedLog();
assert.equal(finished.failure_stage, 'controller_or_persistence');
assert.equal(finished.upload.parser_status, 'parsed');
assert.deepEqual(finished.upload.body_fields, ['id_proveedor']);
assert.equal(finished.upload.file_count, 1);
assert.equal(finished.upload.files[0].original_extension, '.jpg');
assert.equal(finished.upload.files[0].local_file_created, true);
});