Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades

Backend (water-api/):
- Crear API REST completa con Express + TypeScript
- Implementar autenticación JWT con refresh tokens
- CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles
- Agregar validación con Zod para todas las entidades
- Implementar webhooks para The Things Stack (LoRaWAN)
- Agregar endpoint de lecturas con filtros y resumen de consumo
- Implementar carga masiva de medidores via Excel (.xlsx)

Frontend:
- Crear cliente HTTP con manejo automático de JWT y refresh
- Actualizar todas las APIs para usar nuevo backend
- Agregar sistema de autenticación real (login, logout, me)
- Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores
- Agregar campo Meter ID en medidores
- Crear modal de carga masiva para medidores
- Agregar página de consumo con gráficas y filtros
- Corregir carga de proyectos independiente de datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Exteban08
2026-01-23 10:13:26 +00:00
parent 2b5735d78d
commit c81a18987f
92 changed files with 14088 additions and 1866 deletions

View File

137
water-api/src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,137 @@
import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
import config from '../config';
import logger from './logger';
interface TokenPayload {
id: string;
email?: string;
role?: string;
[key: string]: unknown;
}
/**
* Generate an access token
* @param payload - Data to encode in the token
* @returns Signed JWT access token
*/
export const generateAccessToken = (payload: TokenPayload): string => {
const options: SignOptions = {
expiresIn: config.jwt.accessTokenExpiresIn as SignOptions['expiresIn'],
algorithm: 'HS256',
};
try {
const token = jwt.sign(payload, config.jwt.accessTokenSecret, options);
return token;
} catch (error) {
logger.error('Error generating access token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to generate access token');
}
};
/**
* Generate a refresh token
* @param payload - Data to encode in the token
* @returns Signed JWT refresh token
*/
export const generateRefreshToken = (payload: TokenPayload): string => {
const options: SignOptions = {
expiresIn: config.jwt.refreshTokenExpiresIn as SignOptions['expiresIn'],
algorithm: 'HS256',
};
try {
const token = jwt.sign(payload, config.jwt.refreshTokenSecret, options);
return token;
} catch (error) {
logger.error('Error generating refresh token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to generate refresh token');
}
};
/**
* Verify an access token
* @param token - JWT access token to verify
* @returns Decoded payload if valid, null if invalid or expired
*/
export const verifyAccessToken = (token: string): JwtPayload | null => {
const options: VerifyOptions = {
algorithms: ['HS256'],
};
try {
const decoded = jwt.verify(
token,
config.jwt.accessTokenSecret,
options
) as JwtPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.debug('Access token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
logger.debug('Invalid access token', {
error: error.message,
});
} else {
logger.error('Error verifying access token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return null;
}
};
/**
* Verify a refresh token
* @param token - JWT refresh token to verify
* @returns Decoded payload if valid, null if invalid or expired
*/
export const verifyRefreshToken = (token: string): JwtPayload | null => {
const options: VerifyOptions = {
algorithms: ['HS256'],
};
try {
const decoded = jwt.verify(
token,
config.jwt.refreshTokenSecret,
options
) as JwtPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.debug('Refresh token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
logger.debug('Invalid refresh token', {
error: error.message,
});
} else {
logger.error('Error verifying refresh token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return null;
}
};
/**
* Decode a token without verification (for debugging)
* @param token - JWT token to decode
* @returns Decoded payload or null
*/
export const decodeToken = (token: string): JwtPayload | null => {
try {
const decoded = jwt.decode(token) as JwtPayload | null;
return decoded;
} catch (error) {
logger.error('Error decoding token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
};

View File

@@ -0,0 +1,39 @@
import winston from 'winston';
const { combine, timestamp, printf, colorize, errors } = winston.format;
const logFormat = printf(({ level, message, timestamp, stack }) => {
return `${timestamp} [${level}]: ${stack || message}`;
});
const getLogLevel = (): string => {
const env = process.env.NODE_ENV || 'development';
switch (env) {
case 'production':
return 'warn';
case 'test':
return 'error';
default:
return 'debug';
}
};
const logger = winston.createLogger({
level: getLogLevel(),
format: combine(
errors({ stack: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' })
),
transports: [
new winston.transports.Console({
format: combine(
colorize({ all: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
}),
],
exitOnError: false,
});
export default logger;

View File

@@ -0,0 +1,43 @@
import bcrypt from 'bcrypt';
import logger from './logger';
const SALT_ROUNDS = 12;
/**
* Hash a password using bcrypt
* @param password - Plain text password to hash
* @returns Hashed password
*/
export const hashPassword = async (password: string): Promise<string> => {
try {
const salt = await bcrypt.genSalt(SALT_ROUNDS);
const hashedPassword = await bcrypt.hash(password, salt);
return hashedPassword;
} catch (error) {
logger.error('Error hashing password', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to hash password');
}
};
/**
* Compare a plain text password with a hashed password
* @param password - Plain text password to compare
* @param hash - Hashed password to compare against
* @returns True if passwords match, false otherwise
*/
export const comparePassword = async (
password: string,
hash: string
): Promise<boolean> => {
try {
const isMatch = await bcrypt.compare(password, hash);
return isMatch;
} catch (error) {
logger.error('Error comparing passwords', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to compare passwords');
}
};