Files
GRH/water-api/src/services/concentrator.service.ts
Exteban08 613fb2d787 Add 3-level role permissions, organismos operadores, and Histórico de Tomas page
Implements the full ADMIN → ORGANISMO_OPERADOR → OPERATOR permission hierarchy
with scope-filtered data access across all backend services. Adds organismos
operadores management (ADMIN only) and a new Histórico page for viewing
per-meter reading history with chart, consumption stats, and CSV export.

Key changes:
- Backend: 3-level scope filtering on all services (meters, readings, projects, users)
- Backend: Protect GET /meters routes with authenticateToken for role-based filtering
- Backend: Pass requestingUser to reading service for scoped meter readings
- Frontend: New HistoricoPage with meter selector, AreaChart, paginated table
- Frontend: Consumption cards (Actual, Pasado, Diferencial) above date filters
- Frontend: Meter search by name, serial, location, CESPT account, cadastral key
- Frontend: OrganismosPage, updated Sidebar with 3-level visibility
- SQL migrations for organismos_operadores table and FK columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:21:33 +00:00

313 lines
8.3 KiB
TypeScript

import { pool } from '../config/database';
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
/**
* Concentrator types
*/
export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES';
export type ConcentratorStatus = 'ACTIVE' | 'INACTIVE' | 'MAINTENANCE' | 'OFFLINE';
/**
* Concentrator entity interface
*/
export interface Concentrator {
id: string;
serial_number: string;
name: string | null;
project_id: string;
location: string | null;
type: ConcentratorType;
status: ConcentratorStatus;
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
* @param requestingUser - User making the request (for role-based filtering)
* @returns Paginated list of concentrators
*/
export async function getAll(
filters?: ConcentratorFilters,
pagination?: PaginationOptions,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): 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;
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
// Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
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;
}