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>
297 lines
7.3 KiB
TypeScript
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;
|
|
}
|