Implement availability routes and controller for managing online availability

This commit is contained in:
abiandev 2026-06-10 10:34:47 +02:00
parent 8a5cdba7df
commit 9b4ea0b415
5 changed files with 505 additions and 0 deletions

2
app.js
View File

@ -7,6 +7,7 @@ const authRoutes = require('./src/routes/authRoutes');
const profileRoutes = require('./src/routes/profileRoutes'); const profileRoutes = require('./src/routes/profileRoutes');
const tripsRoutes = require('./src/routes/tripsRoutes'); const tripsRoutes = require('./src/routes/tripsRoutes');
const driverLicenseRoutes = require('./src/routes/driverLicenseRoutes'); const driverLicenseRoutes = require('./src/routes/driverLicenseRoutes');
const availabilityRoutes = require('./src/routes/availabilityRoutes');
const { appendPostLog } = require('./src/utils/postLog'); const { appendPostLog } = require('./src/utils/postLog');
dotenv.config({ path: path.resolve(__dirname, '.env'), override: true }); dotenv.config({ path: path.resolve(__dirname, '.env'), override: true });
@ -107,6 +108,7 @@ app.use('/', authRoutes);
app.use('/', profileRoutes); app.use('/', profileRoutes);
app.use('/', require('./src/routes/locationRoutes')); app.use('/', require('./src/routes/locationRoutes'));
app.use('/api', require('./src/routes/stressRoutes')); // Stress Test Endpoint app.use('/api', require('./src/routes/stressRoutes')); // Stress Test Endpoint
app.use('/api', availabilityRoutes);
app.use('/api', tripsRoutes); app.use('/api', tripsRoutes);
app.use('/api', driverLicenseRoutes); app.use('/api', driverLicenseRoutes);

View File

@ -0,0 +1,102 @@
const db = require('../config/db');
const getCoordinatesFromBody = (body) => {
const lat = body?.latitud ?? body?.latitude;
const lng = body?.longitud ?? body?.longitude;
if (lat === undefined || lat === null || lng === undefined || lng === null) {
return null;
}
return { lat, lng };
};
const upsertOnlineAvailability = async (dni, lat, lng) => {
const [rows] = await db.query(
`SELECT id_usuario
FROM c_trazabilidad_online
WHERE id_usuario = ?
LIMIT 1`,
[dni]
);
if (rows.length > 0) {
await db.query(
`UPDATE c_trazabilidad_online
SET latitud = ?, longitud = ?, fecha = NOW()
WHERE id_usuario = ?`,
[String(lat), String(lng), dni]
);
return;
}
await db.query(
`INSERT INTO c_trazabilidad_online
(latitud, longitud, id_usuario, fecha)
VALUES (?, ?, ?, NOW())`,
[String(lat), String(lng), dni]
);
};
const getAvailability = async (req, res) => {
try {
const dni = String(req.user.dni);
const [rows] = await db.query(
`SELECT COUNT(*) AS total
FROM c_trazabilidad_online
WHERE id_usuario = ?`,
[dni]
);
return res.json({
success: true,
available: Number(rows[0]?.total || 0) > 0
});
} catch (error) {
console.error('Error getting availability:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
const setAvailability = async (req, res) => {
try {
const coords = getCoordinatesFromBody(req.body);
if (!coords) {
return res.status(400).json({
success: false,
error: 'missing_coords',
message: 'latitud/longitud or latitude/longitude are required'
});
}
await upsertOnlineAvailability(String(req.user.dni), coords.lat, coords.lng);
return res.json({ success: true, available: true });
} catch (error) {
console.error('Error setting availability:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
const deleteAvailability = async (req, res) => {
try {
await db.query(
`DELETE FROM c_trazabilidad_online
WHERE id_usuario = ?`,
[String(req.user.dni)]
);
return res.json({ success: true, available: false });
} catch (error) {
console.error('Error deleting availability:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
module.exports = {
deleteAvailability,
getAvailability,
setAvailability,
upsertOnlineAvailability
};

View File

@ -1,5 +1,6 @@
const db = require('../config/db'); const db = require('../config/db');
const agheeraPushClient = require('../services/agheeraPushClient'); const agheeraPushClient = require('../services/agheeraPushClient');
const { upsertOnlineAvailability } = require('./availabilityController');
const AGHEERA_CLIENT_ID = 532; const AGHEERA_CLIENT_ID = 532;
@ -73,6 +74,22 @@ const getTripIdFromLocation = (locationData) => {
return null; return null;
}; };
const isAvailabilityModeEnabled = (value) =>
value === true || value === 'true' || value === 1 || value === '1';
const hasAvailabilityModeEnabled = (data, loc) => {
const candidates = [
data?.availability_mode,
data?.params?.availability_mode,
data?.extras?.availability_mode,
loc?.availability_mode,
loc?.params?.availability_mode,
loc?.extras?.availability_mode
];
return candidates.some(isAvailabilityModeEnabled);
};
const getRawTimestampFromLocation = (locationData) => { const getRawTimestampFromLocation = (locationData) => {
const candidates = [ const candidates = [
locationData?.timestamp, locationData?.timestamp,
@ -241,6 +258,7 @@ const saveLocation = async (req, res) => {
const rowsToInsert = []; const rowsToInsert = [];
const locationsToPush = []; const locationsToPush = [];
const onlineAvailabilityUpdates = [];
for (const loc of locations) { for (const loc of locations) {
const coords = getCoordinatesFromLocation(loc); const coords = getCoordinatesFromLocation(loc);
@ -274,6 +292,13 @@ const saveLocation = async (req, res) => {
tripId, tripId,
measurementTime: persistedTimestamp.value measurementTime: persistedTimestamp.value
}); });
if (dni && hasAvailabilityModeEnabled(data, loc)) {
onlineAvailabilityUpdates.push({
dni,
lat: coords.lat,
lng: coords.lng
});
}
} }
} }
@ -293,6 +318,11 @@ const saveLocation = async (req, res) => {
); );
const agheeraResults = await pushLocationsToAgheera(locationsToPush); const agheeraResults = await pushLocationsToAgheera(locationsToPush);
for (const update of onlineAvailabilityUpdates) {
await upsertOnlineAvailability(update.dni, update.lat, update.lng);
}
const responseBody = { const responseBody = {
success: true, success: true,
count: rowsToInsert.length, count: rowsToInsert.length,

View File

@ -0,0 +1,11 @@
const express = require('express');
const availabilityController = require('../controllers/availabilityController');
const authenticateDevice = require('../middleware/auth');
const router = express.Router();
router.get('/availability', authenticateDevice, availabilityController.getAvailability);
router.post('/availability', authenticateDevice, availabilityController.setAvailability);
router.delete('/availability', authenticateDevice, availabilityController.deleteAvailability);
module.exports = router;

View File

@ -0,0 +1,360 @@
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 TEST_JWT_SECRET = 'test-jwt-secret';
let originalQuery;
let originalJwtSecret;
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 requestJson = async ({ port, method, path, authorization, body }) =>
new Promise((resolve, reject) => {
const rawBody = body === undefined ? null : JSON.stringify(body);
const headers = {};
if (authorization) {
headers.authorization = authorization;
}
if (rawBody !== null) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(rawBody);
}
const req = http.request(
{
hostname: '127.0.0.1',
port,
method,
path,
headers
},
(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);
if (rawBody !== null) {
req.write(rawBody);
}
req.end();
});
test.before(() => {
originalQuery = db.query;
originalJwtSecret = process.env.JWT_SECRET;
});
test.after(() => {
db.query = originalQuery;
process.env.JWT_SECRET = originalJwtSecret;
});
test.afterEach(() => {
db.query = originalQuery;
});
test('GET /api/availability devuelve available false si no hay fila', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql, params) => {
assert.match(sql, /COUNT\(\*\) AS total/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[{ total: 0 }]];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'GET',
path: '/api/availability',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: false });
});
test('GET /api/availability devuelve available true si hay fila', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql, params) => {
assert.match(sql, /COUNT\(\*\) AS total/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[{ total: 1 }]];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'GET',
path: '/api/availability',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: true });
});
test('POST /api/availability hace INSERT si no existe', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /SELECT id_usuario/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[]];
}
assert.match(sql, /INSERT INTO c_trazabilidad_online/);
assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/availability',
authorization: `Bearer ${createToken()}`,
body: {
latitud: 40.416775,
longitud: -3.70379,
usuario: 'OTHER'
}
})
);
assert.equal(step, 2);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: true });
});
test('POST /api/availability hace UPDATE si existe', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /SELECT id_usuario/);
assert.deepEqual(params, ['58045340X']);
return [[{ id_usuario: '58045340X' }]];
}
assert.match(sql, /UPDATE c_trazabilidad_online/);
assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/availability',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379
}
})
);
assert.equal(step, 2);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: true });
});
test('DELETE /api/availability borra la fila', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql, params) => {
assert.match(sql, /DELETE FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'DELETE',
path: '/api/availability',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: false });
});
test('POST /api/locations con availability_mode true actualiza disponibilidad online', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
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']);
return [{ affectedRows: 1 }];
}
if (step === 2) {
assert.match(sql, /SELECT id_usuario/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[{ id_usuario: '58045340X' }]];
}
assert.match(sql, /UPDATE c_trazabilidad_online/);
assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
location: [
{
coords: {
latitude: 40.416775,
longitude: -3.70379
},
params: {
availability_mode: 'true'
},
timestamp: '2026-06-01T13:20:00Z'
}
]
}
})
);
assert.equal(step, 3);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved'
});
});
test('POST /api/locations sin availability_mode no toca disponibilidad online', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let calls = 0;
db.query = async (sql, params) => {
calls += 1;
assert.match(sql, /INSERT INTO c_trazabilidad_transportista/);
assert.deepEqual(params[0][0].slice(0, 3), ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379,
timestamp: '2026-06-01T13:20:00Z'
}
})
);
assert.equal(calls, 1);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved'
});
});
test('todas las rutas nuevas requieren JWT valido', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async () => {
throw new Error('db.query should not be called without token');
};
const responses = await withServer(async (server) => {
const port = server.address().port;
return Promise.all([
requestJson({ port, method: 'GET', path: '/api/availability' }),
requestJson({ port, method: 'POST', path: '/api/availability', body: { latitude: 1, longitude: 2 } }),
requestJson({ port, method: 'DELETE', path: '/api/availability' })
]);
});
assert.deepEqual(
responses.map((response) => response.statusCode),
[401, 401, 401]
);
});