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:
296
water-api/src/services/concentrator.service.ts
Normal file
296
water-api/src/services/concentrator.service.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user