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

@@ -0,0 +1,252 @@
import { query } from '../config/database';
import {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
} from '../utils/jwt';
import { comparePassword } from '../utils/password';
import crypto from 'crypto';
/**
* Hash a token for storage
*/
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* Authentication service response types
*/
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
role: string;
avatarUrl?: string | null;
createdAt: Date;
}
export interface LoginResult extends AuthTokens {
user: UserProfile;
}
/**
* Authenticate user with email and password
* Generates access and refresh tokens on successful login
* Stores hashed refresh token in database
* @param email - User email
* @param password - User password
* @returns Access and refresh tokens with user info
*/
export async function login(
email: string,
password: string
): Promise<LoginResult> {
// Find user by email with role name
const userResult = await query<{
id: string;
email: string;
name: string;
password_hash: string;
avatar_url: string | null;
role_name: string;
created_at: Date;
}>(
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.created_at
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
LIMIT 1`,
[email]
);
const user = userResult.rows[0];
if (!user) {
throw new Error('Invalid email or password');
}
// Verify password
const isValidPassword = await comparePassword(password, user.password_hash);
if (!isValidPassword) {
throw new Error('Invalid email or password');
}
// Generate tokens
const accessToken = generateAccessToken({
id: user.id,
email: user.email,
role: user.role_name,
});
const refreshToken = generateRefreshToken({
id: user.id,
});
// Hash and store refresh token
const hashedRefreshToken = hashToken(refreshToken);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, hashedRefreshToken, expiresAt]
);
// Update last login
await query(
`UPDATE users SET last_login = NOW() WHERE id = $1`,
[user.id]
);
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role_name,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
},
};
}
/**
* Refresh access token using a valid refresh token
* Verifies the refresh token exists in database and is not expired
* @param refreshToken - The refresh token
* @returns New access token
*/
export async function refresh(refreshToken: string): Promise<{ accessToken: string }> {
// Verify JWT signature
const decoded = verifyRefreshToken(refreshToken);
if (!decoded) {
throw new Error('Invalid refresh token');
}
// Hash token to check against database
const hashedToken = hashToken(refreshToken);
// Find token in database
const tokenResult = await query<{
id: string;
expires_at: Date;
}>(
`SELECT id, expires_at FROM refresh_tokens
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
LIMIT 1`,
[hashedToken, decoded.id]
);
const storedToken = tokenResult.rows[0];
if (!storedToken) {
throw new Error('Refresh token not found or revoked');
}
// Check if token is expired
if (new Date() > storedToken.expires_at) {
// Clean up expired token
await query(
`DELETE FROM refresh_tokens WHERE id = $1`,
[storedToken.id]
);
throw new Error('Refresh token expired');
}
// Get user data for new access token
const userResult = await query<{
id: string;
email: string;
role_name: string;
}>(
`SELECT u.id, u.email, r.name as role_name
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true
LIMIT 1`,
[decoded.id]
);
const user = userResult.rows[0];
if (!user) {
throw new Error('User not found');
}
// Generate new access token
const accessToken = generateAccessToken({
id: user.id,
email: user.email,
role: user.role_name,
});
return { accessToken };
}
/**
* Logout user by revoking the specified refresh token
* @param userId - The user ID
* @param refreshToken - The refresh token to revoke
*/
export async function logout(
userId: string,
refreshToken: string
): Promise<void> {
const hashedToken = hashToken(refreshToken);
// Revoke the specific refresh token
await query(
`UPDATE refresh_tokens SET revoked_at = NOW()
WHERE token_hash = $1 AND user_id = $2`,
[hashedToken, userId]
);
}
/**
* Get authenticated user's profile
* @param userId - The user ID
* @returns User profile data
*/
export async function getMe(userId: string): Promise<UserProfile> {
const userResult = await query<{
id: string;
email: string;
name: string;
avatar_url: string | null;
role_name: string;
created_at: Date;
}>(
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.created_at
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true
LIMIT 1`,
[userId]
);
const user = userResult.rows[0];
if (!user) {
throw new Error('User not found');
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role_name,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
};
}