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>
309 lines
8.2 KiB
TypeScript
309 lines
8.2 KiB
TypeScript
import { query } from '../config/database';
|
|
import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../validators/project.validator';
|
|
|
|
/**
|
|
* Project interface matching database schema
|
|
*/
|
|
export interface Project {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
area_name: string | null;
|
|
location: string | null;
|
|
status: ProjectStatusType;
|
|
created_by: string | null;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
}
|
|
|
|
/**
|
|
* Project statistics interface
|
|
*/
|
|
export interface ProjectStats {
|
|
meter_count: number;
|
|
device_count: number;
|
|
concentrator_count: number;
|
|
active_meters: number;
|
|
inactive_meters: number;
|
|
}
|
|
|
|
/**
|
|
* Pagination parameters interface
|
|
*/
|
|
export interface PaginationParams {
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
/**
|
|
* Filter parameters for projects
|
|
*/
|
|
export interface ProjectFilters {
|
|
status?: ProjectStatusType;
|
|
area_name?: string;
|
|
search?: string;
|
|
}
|
|
|
|
/**
|
|
* Paginated result interface
|
|
*/
|
|
export interface PaginatedResult<T> {
|
|
data: T[];
|
|
pagination: {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all projects with optional filtering and pagination
|
|
* @param filters - Optional filters for status and area_name
|
|
* @param pagination - Optional pagination parameters
|
|
* @returns Paginated list of projects
|
|
*/
|
|
export async function getAll(
|
|
filters?: ProjectFilters,
|
|
pagination?: PaginationParams
|
|
): Promise<PaginatedResult<Project>> {
|
|
const page = pagination?.page || 1;
|
|
const pageSize = pagination?.pageSize || 10;
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
// Build WHERE clause dynamically
|
|
const conditions: string[] = [];
|
|
const params: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (filters?.status) {
|
|
conditions.push(`status = $${paramIndex}`);
|
|
params.push(filters.status);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (filters?.area_name) {
|
|
conditions.push(`area_name ILIKE $${paramIndex}`);
|
|
params.push(`%${filters.area_name}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (filters?.search) {
|
|
conditions.push(`(name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
|
params.push(`%${filters.search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
// Get total count
|
|
const countQuery = `SELECT COUNT(*) as total FROM projects ${whereClause}`;
|
|
const countResult = await query<{ total: string }>(countQuery, params);
|
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
|
|
|
// Get paginated data
|
|
const dataQuery = `
|
|
SELECT id, name, description, area_name, location, status, created_by, created_at, updated_at
|
|
FROM projects
|
|
${whereClause}
|
|
ORDER BY created_at DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
`;
|
|
params.push(pageSize, offset);
|
|
|
|
const result = await query<Project>(dataQuery, params);
|
|
|
|
return {
|
|
data: result.rows,
|
|
pagination: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a single project by ID
|
|
* @param id - Project UUID
|
|
* @returns Project or null if not found
|
|
*/
|
|
export async function getById(id: string): Promise<Project | null> {
|
|
const result = await query<Project>(
|
|
`SELECT id, name, description, area_name, location, status, created_by, created_at, updated_at
|
|
FROM projects
|
|
WHERE id = $1`,
|
|
[id]
|
|
);
|
|
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Create a new project
|
|
* @param data - Project data
|
|
* @param userId - ID of the user creating the project
|
|
* @returns Created project
|
|
*/
|
|
export async function create(data: CreateProjectInput, userId: string): Promise<Project> {
|
|
const result = await query<Project>(
|
|
`INSERT INTO projects (name, description, area_name, location, status, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, name, description, area_name, location, status, created_by, created_at, updated_at`,
|
|
[
|
|
data.name,
|
|
data.description || null,
|
|
data.area_name || null,
|
|
data.location || null,
|
|
data.status || 'ACTIVE',
|
|
userId,
|
|
]
|
|
);
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
/**
|
|
* Update an existing project
|
|
* @param id - Project UUID
|
|
* @param data - Updated project data
|
|
* @returns Updated project or null if not found
|
|
*/
|
|
export async function update(id: string, data: UpdateProjectInput): Promise<Project | null> {
|
|
// Build SET clause dynamically based on provided fields
|
|
const updates: string[] = [];
|
|
const params: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (data.name !== undefined) {
|
|
updates.push(`name = $${paramIndex}`);
|
|
params.push(data.name);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (data.description !== undefined) {
|
|
updates.push(`description = $${paramIndex}`);
|
|
params.push(data.description);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (data.area_name !== undefined) {
|
|
updates.push(`area_name = $${paramIndex}`);
|
|
params.push(data.area_name);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (data.location !== undefined) {
|
|
updates.push(`location = $${paramIndex}`);
|
|
params.push(data.location);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (data.status !== undefined) {
|
|
updates.push(`status = $${paramIndex}`);
|
|
params.push(data.status);
|
|
paramIndex++;
|
|
}
|
|
|
|
// Always update the updated_at timestamp
|
|
updates.push(`updated_at = NOW()`);
|
|
|
|
if (updates.length === 1) {
|
|
// Only updated_at was added, no actual data to update
|
|
return getById(id);
|
|
}
|
|
|
|
params.push(id);
|
|
|
|
const result = await query<Project>(
|
|
`UPDATE projects
|
|
SET ${updates.join(', ')}
|
|
WHERE id = $${paramIndex}
|
|
RETURNING id, name, description, area_name, location, status, created_by, created_at, updated_at`,
|
|
params
|
|
);
|
|
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Delete a project by ID
|
|
* Checks for dependent meters/concentrators before deletion
|
|
* @param id - Project UUID
|
|
* @returns True if deleted, throws error if has dependencies
|
|
*/
|
|
export async function deleteProject(id: string): Promise<boolean> {
|
|
// Check for dependent meters
|
|
const meterCheck = await query<{ count: string }>(
|
|
'SELECT COUNT(*) as count FROM meters WHERE project_id = $1',
|
|
[id]
|
|
);
|
|
const meterCount = parseInt(meterCheck.rows[0]?.count || '0', 10);
|
|
|
|
if (meterCount > 0) {
|
|
throw new Error(`Cannot delete project: ${meterCount} meter(s) are associated with this project`);
|
|
}
|
|
|
|
// Check for dependent concentrators
|
|
const concentratorCheck = await query<{ count: string }>(
|
|
'SELECT COUNT(*) as count FROM concentrators WHERE project_id = $1',
|
|
[id]
|
|
);
|
|
const concentratorCount = parseInt(concentratorCheck.rows[0]?.count || '0', 10);
|
|
|
|
if (concentratorCount > 0) {
|
|
throw new Error(`Cannot delete project: ${concentratorCount} concentrator(s) are associated with this project`);
|
|
}
|
|
|
|
const result = await query('DELETE FROM projects WHERE id = $1', [id]);
|
|
|
|
return (result.rowCount || 0) > 0;
|
|
}
|
|
|
|
/**
|
|
* Get project statistics
|
|
* @param id - Project UUID
|
|
* @returns Project statistics including meter count, device count, etc.
|
|
*/
|
|
export async function getStats(id: string): Promise<ProjectStats | null> {
|
|
// Verify project exists
|
|
const project = await getById(id);
|
|
if (!project) {
|
|
return null;
|
|
}
|
|
|
|
// Get meter counts
|
|
const meterStats = await query<{ total: string; active: string; inactive: string }>(
|
|
`SELECT
|
|
COUNT(*) as total,
|
|
COUNT(*) FILTER (WHERE status = 'ACTIVE' OR status = 'active') as active,
|
|
COUNT(*) FILTER (WHERE status = 'INACTIVE' OR status = 'inactive') as inactive
|
|
FROM meters
|
|
WHERE project_id = $1`,
|
|
[id]
|
|
);
|
|
|
|
// Get device count (devices linked to meters in this project)
|
|
const deviceStats = await query<{ count: string }>(
|
|
`SELECT COUNT(DISTINCT device_id) as count
|
|
FROM meters
|
|
WHERE project_id = $1 AND device_id IS NOT NULL`,
|
|
[id]
|
|
);
|
|
|
|
// Get concentrator count
|
|
const concentratorStats = await query<{ count: string }>(
|
|
'SELECT COUNT(*) as count FROM concentrators WHERE project_id = $1',
|
|
[id]
|
|
);
|
|
|
|
return {
|
|
meter_count: parseInt(meterStats.rows[0]?.total || '0', 10),
|
|
active_meters: parseInt(meterStats.rows[0]?.active || '0', 10),
|
|
inactive_meters: parseInt(meterStats.rows[0]?.inactive || '0', 10),
|
|
device_count: parseInt(deviceStats.rows[0]?.count || '0', 10),
|
|
concentrator_count: parseInt(concentratorStats.rows[0]?.count || '0', 10),
|
|
};
|
|
}
|