Files
GRH/water-api/src/services/concentrator.service.ts
Exteban08 c81a18987f 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>
2026-01-23 10:13:26 +00:00

297 lines
7.3 KiB
TypeScript

import { pool } from '../config/database';
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
/**
* Concentrator types
*/
export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES';
/**
* Concentrator entity interface
*/
export interface Concentrator {
id: string;
serial_number: string;
name: string | null;
project_id: string;
location: string | null;
type: ConcentratorType;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
ip_address: string | null;
firmware_version: string | null;
created_at: Date;
updated_at: Date;
}
/**
* Concentrator with gateway count
*/
export interface ConcentratorWithCount extends Concentrator {
gateway_count: number;
}
/**
* Filter options for concentrators
*/
export interface ConcentratorFilters {
project_id?: string;
status?: string;
type?: ConcentratorType;
}
/**
* Pagination options
*/
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
/**
* Paginated result
*/
export interface PaginatedResult<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Get all concentrators with optional filters and pagination
* @param filters - Optional filter criteria
* @param pagination - Optional pagination options
* @returns Paginated list of concentrators
*/
export async function getAll(
filters?: ConcentratorFilters,
pagination?: PaginationOptions
): Promise<PaginatedResult<Concentrator>> {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const offset = (page - 1) * limit;
const sortBy = pagination?.sortBy || 'created_at';
const sortOrder = pagination?.sortOrder || 'desc';
// Build WHERE clause
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters?.project_id) {
conditions.push(`project_id = $${paramIndex}`);
params.push(filters.project_id);
paramIndex++;
}
if (filters?.status) {
conditions.push(`status = $${paramIndex}`);
params.push(filters.status);
paramIndex++;
}
if (filters?.type) {
conditions.push(`type = $${paramIndex}`);
params.push(filters.type);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Validate sort column to prevent SQL injection
const allowedSortColumns = ['id', 'serial_number', 'name', 'status', 'created_at', 'updated_at'];
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
// Get total count
const countQuery = `SELECT COUNT(*) FROM concentrators ${whereClause}`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0].count, 10);
// Get data
const dataQuery = `
SELECT * FROM concentrators
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(limit, offset);
const dataResult = await pool.query<Concentrator>(dataQuery, params);
const totalPages = Math.ceil(total / limit);
return {
data: dataResult.rows,
pagination: {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Get a single concentrator by ID with gateway count
* @param id - Concentrator UUID
* @returns Concentrator with gateway count or null
*/
export async function getById(id: string): Promise<ConcentratorWithCount | null> {
const query = `
SELECT
c.*,
COALESCE(COUNT(g.id), 0)::int as gateway_count
FROM concentrators c
LEFT JOIN gateways g ON g.concentrator_id = c.id
WHERE c.id = $1
GROUP BY c.id
`;
const result = await pool.query<ConcentratorWithCount>(query, [id]);
return result.rows[0] || null;
}
/**
* Create a new concentrator
* @param data - Concentrator creation data
* @returns Created concentrator
*/
export async function create(data: CreateConcentratorInput): Promise<Concentrator> {
const query = `
INSERT INTO concentrators (serial_number, name, project_id, location, type, status, ip_address, firmware_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const params = [
data.serial_number,
data.name || null,
data.project_id,
data.location || null,
data.type || 'LORA',
data.status || 'ACTIVE',
data.ip_address || null,
data.firmware_version || null,
];
const result = await pool.query<Concentrator>(query, params);
return result.rows[0];
}
/**
* Update an existing concentrator
* @param id - Concentrator UUID
* @param data - Update data
* @returns Updated concentrator or null if not found
*/
export async function update(id: string, data: UpdateConcentratorInput): Promise<Concentrator | null> {
// Build SET clause dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.serial_number !== undefined) {
updates.push(`serial_number = $${paramIndex}`);
params.push(data.serial_number);
paramIndex++;
}
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.project_id !== undefined) {
updates.push(`project_id = $${paramIndex}`);
params.push(data.project_id);
paramIndex++;
}
if (data.location !== undefined) {
updates.push(`location = $${paramIndex}`);
params.push(data.location);
paramIndex++;
}
if (data.type !== undefined) {
updates.push(`type = $${paramIndex}`);
params.push(data.type);
paramIndex++;
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex}`);
params.push(data.status);
paramIndex++;
}
if (data.ip_address !== undefined) {
updates.push(`ip_address = $${paramIndex}`);
params.push(data.ip_address);
paramIndex++;
}
if (data.firmware_version !== undefined) {
updates.push(`firmware_version = $${paramIndex}`);
params.push(data.firmware_version);
paramIndex++;
}
if (updates.length === 0) {
// No updates provided, return existing record
const existing = await getById(id);
return existing;
}
updates.push(`updated_at = NOW()`);
const query = `
UPDATE concentrators
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await pool.query<Concentrator>(query, params);
return result.rows[0] || null;
}
/**
* Delete a concentrator
* Checks for dependent gateways before deletion
* @param id - Concentrator UUID
* @returns True if deleted, throws error if has dependencies
*/
export async function remove(id: string): Promise<boolean> {
// Check for dependent gateways
const gatewayCheck = await pool.query(
'SELECT COUNT(*) FROM gateways WHERE concentrator_id = $1',
[id]
);
const gatewayCount = parseInt(gatewayCheck.rows[0].count, 10);
if (gatewayCount > 0) {
throw new Error(`Cannot delete concentrator: ${gatewayCount} gateway(s) are associated with it`);
}
const result = await pool.query(
'DELETE FROM concentrators WHERE id = $1 RETURNING id',
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}