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