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:
Exteban08
2026-01-23 10:13:26 +00:00
parent 2b5735d78d
commit c81a18987f
92 changed files with 14088 additions and 1866 deletions

View File

View File

@@ -0,0 +1,138 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as authService from '../services/auth.service';
import { LoginInput, RefreshInput } from '../validators/auth.validator';
/**
* POST /auth/login
* Authenticate user with email and password
* Returns access token and refresh token
*/
export async function login(req: Request, res: Response): Promise<void> {
try {
const { email, password } = req.body as LoginInput;
const result = await authService.login(email, password);
res.status(200).json({
success: true,
data: {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Login failed';
// Use 401 for authentication failures
if (message === 'Invalid email or password') {
res.status(401).json({ success: false, error: message });
return;
}
res.status(500).json({ success: false, error: 'Internal server error' });
}
}
/**
* POST /auth/refresh
* Generate new access token using refresh token
* Returns new access token
*/
export async function refresh(req: Request, res: Response): Promise<void> {
try {
const { refreshToken } = req.body as RefreshInput;
const result = await authService.refresh(refreshToken);
res.status(200).json({
success: true,
data: {
accessToken: result.accessToken,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Token refresh failed';
// Use 401 for invalid/expired tokens
if (
message === 'Invalid refresh token' ||
message === 'Refresh token not found or revoked' ||
message === 'Refresh token expired'
) {
res.status(401).json({ success: false, error: message });
return;
}
res.status(500).json({ success: false, error: 'Internal server error' });
}
}
/**
* POST /auth/logout
* Invalidate the refresh token
* Requires authentication
*/
export async function logout(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' });
return;
}
const { refreshToken } = req.body as RefreshInput;
if (refreshToken) {
await authService.logout(userId, refreshToken);
}
res.status(200).json({
success: true,
message: 'Logout successful',
});
} catch (error) {
res.status(500).json({ success: false, error: 'Internal server error' });
}
}
/**
* GET /auth/me
* Get authenticated user's profile
* Requires authentication
*/
export async function getMe(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' });
return;
}
const profile = await authService.getMe(userId);
// Transform avatarUrl to avatar_url for frontend compatibility
res.status(200).json({
success: true,
data: {
id: profile.id,
email: profile.email,
name: profile.name,
role: profile.role,
avatar_url: profile.avatarUrl,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get profile';
if (message === 'User not found') {
res.status(404).json({ success: false, error: message });
return;
}
res.status(500).json({ success: false, error: 'Internal server error' });
}
}

View File

@@ -0,0 +1,82 @@
import { Request, Response } from 'express';
import multer from 'multer';
import { bulkUploadMeters, generateMeterTemplate } from '../services/bulk-upload.service';
// Configure multer for memory storage
const storage = multer.memoryStorage();
export const upload = multer({
storage,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
fileFilter: (_req, file, cb) => {
// Accept Excel files only
const allowedMimes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Solo se permiten archivos Excel (.xlsx, .xls)'));
}
},
});
/**
* POST /api/bulk-upload/meters
* Upload Excel file with meters data
*/
export async function uploadMeters(req: Request, res: Response): Promise<void> {
try {
if (!req.file) {
res.status(400).json({
success: false,
error: 'No se proporcionó ningún archivo',
});
return;
}
const result = await bulkUploadMeters(req.file.buffer);
res.status(result.success ? 200 : 207).json({
success: result.success,
data: {
totalRows: result.totalRows,
inserted: result.inserted,
failed: result.errors.length,
errors: result.errors.slice(0, 50), // Limit errors in response
},
});
} catch (err) {
const error = err as Error;
console.error('Error in bulk upload:', error);
res.status(500).json({
success: false,
error: error.message || 'Error procesando la carga masiva',
});
}
}
/**
* GET /api/bulk-upload/meters/template
* Download Excel template for meters
*/
export async function downloadMeterTemplate(_req: Request, res: Response): Promise<void> {
try {
const buffer = generateMeterTemplate();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename=plantilla_medidores.xlsx');
res.send(buffer);
} catch (err) {
const error = err as Error;
console.error('Error generating template:', error);
res.status(500).json({
success: false,
error: 'Error generando la plantilla',
});
}
}

View File

@@ -0,0 +1,202 @@
import { Request, Response } from 'express';
import * as concentratorService from '../services/concentrator.service';
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
/**
* GET /concentrators
* Get all concentrators with optional filters and pagination
* Query params: project_id, status, page, limit, sortBy, sortOrder
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const { project_id, status, page, limit, sortBy, sortOrder } = req.query;
const filters: concentratorService.ConcentratorFilters = {};
if (project_id) filters.project_id = project_id as string;
if (status) filters.status = status as string;
const pagination: concentratorService.PaginationOptions = {
page: page ? parseInt(page as string, 10) : 1,
limit: limit ? parseInt(limit as string, 10) : 10,
sortBy: sortBy as string,
sortOrder: sortOrder as 'asc' | 'desc',
};
const result = await concentratorService.getAll(filters, pagination);
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching concentrators:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch concentrators',
});
}
}
/**
* GET /concentrators/:id
* Get a single concentrator by ID with gateway count
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const concentrator = await concentratorService.getById(id);
if (!concentrator) {
res.status(404).json({
success: false,
error: 'Concentrator not found',
});
return;
}
res.status(200).json({
success: true,
data: concentrator,
});
} catch (error) {
console.error('Error fetching concentrator:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch concentrator',
});
}
}
/**
* POST /concentrators
* Create a new concentrator
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as CreateConcentratorInput;
const concentrator = await concentratorService.create(data);
res.status(201).json({
success: true,
data: concentrator,
});
} catch (error) {
console.error('Error creating concentrator:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('duplicate')) {
res.status(409).json({
success: false,
error: 'A concentrator with this serial number already exists',
});
return;
}
// Check for foreign key violation (invalid project_id)
if (error instanceof Error && error.message.includes('foreign key')) {
res.status(400).json({
success: false,
error: 'Invalid project_id: Project does not exist',
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to create concentrator',
});
}
}
/**
* PUT /concentrators/:id
* Update an existing concentrator
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as UpdateConcentratorInput;
const concentrator = await concentratorService.update(id, data);
if (!concentrator) {
res.status(404).json({
success: false,
error: 'Concentrator not found',
});
return;
}
res.status(200).json({
success: true,
data: concentrator,
});
} catch (error) {
console.error('Error updating concentrator:', error);
if (error instanceof Error && error.message.includes('duplicate')) {
res.status(409).json({
success: false,
error: 'A concentrator with this serial number already exists',
});
return;
}
if (error instanceof Error && error.message.includes('foreign key')) {
res.status(400).json({
success: false,
error: 'Invalid project_id: Project does not exist',
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to update concentrator',
});
}
}
/**
* DELETE /concentrators/:id
* Delete a concentrator
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await concentratorService.remove(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Concentrator not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Concentrator deleted successfully' },
});
} catch (error) {
console.error('Error deleting concentrator:', error);
// Check for dependency error
if (error instanceof Error && error.message.includes('Cannot delete')) {
res.status(409).json({
success: false,
error: error.message,
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to delete concentrator',
});
}
}

View File

@@ -0,0 +1,225 @@
import { Request, Response } from 'express';
import * as deviceService from '../services/device.service';
import { CreateDeviceInput, UpdateDeviceInput } from '../validators/device.validator';
/**
* GET /devices
* Get all devices with optional filters and pagination
* Query params: project_id, gateway_id, status, device_type, page, limit, sortBy, sortOrder
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const { project_id, gateway_id, status, device_type, page, limit, sortBy, sortOrder } = req.query;
const filters: deviceService.DeviceFilters = {};
if (project_id) filters.project_id = project_id as string;
if (gateway_id) filters.gateway_id = gateway_id as string;
if (status) filters.status = status as string;
if (device_type) filters.device_type = device_type as string;
const pagination: deviceService.PaginationOptions = {
page: page ? parseInt(page as string, 10) : 1,
limit: limit ? parseInt(limit as string, 10) : 10,
sortBy: sortBy as string,
sortOrder: sortOrder as 'asc' | 'desc',
};
const result = await deviceService.getAll(filters, pagination);
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching devices:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch devices',
});
}
}
/**
* GET /devices/:id
* Get a single device by ID with meter info
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const device = await deviceService.getById(id);
if (!device) {
res.status(404).json({
success: false,
error: 'Device not found',
});
return;
}
res.status(200).json({
success: true,
data: device,
});
} catch (error) {
console.error('Error fetching device:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch device',
});
}
}
/**
* GET /devices/dev-eui/:devEui
* Get a device by DevEUI
*/
export async function getByDevEui(req: Request, res: Response): Promise<void> {
try {
const { devEui } = req.params;
const device = await deviceService.getByDevEui(devEui);
if (!device) {
res.status(404).json({
success: false,
error: 'Device not found',
});
return;
}
res.status(200).json({
success: true,
data: device,
});
} catch (error) {
console.error('Error fetching device by DevEUI:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch device',
});
}
}
/**
* POST /devices
* Create a new device
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as CreateDeviceInput;
const device = await deviceService.create(data);
res.status(201).json({
success: true,
data: device,
});
} catch (error) {
console.error('Error creating device:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('duplicate')) {
res.status(409).json({
success: false,
error: 'A device with this DevEUI already exists',
});
return;
}
// Check for foreign key violation
if (error instanceof Error && error.message.includes('foreign key')) {
res.status(400).json({
success: false,
error: 'Invalid project_id or gateway_id: Reference does not exist',
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to create device',
});
}
}
/**
* PUT /devices/:id
* Update an existing device
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as UpdateDeviceInput;
const device = await deviceService.update(id, data);
if (!device) {
res.status(404).json({
success: false,
error: 'Device not found',
});
return;
}
res.status(200).json({
success: true,
data: device,
});
} catch (error) {
console.error('Error updating device:', error);
if (error instanceof Error && error.message.includes('duplicate')) {
res.status(409).json({
success: false,
error: 'A device with this DevEUI already exists',
});
return;
}
if (error instanceof Error && error.message.includes('foreign key')) {
res.status(400).json({
success: false,
error: 'Invalid project_id or gateway_id: Reference does not exist',
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to update device',
});
}
}
/**
* DELETE /devices/:id
* Delete a device
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await deviceService.remove(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Device not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Device deleted successfully' },
});
} catch (error) {
console.error('Error deleting device:', error);
res.status(500).json({
success: false,
error: 'Failed to delete device',
});
}
}

View File

@@ -0,0 +1,237 @@
import { Request, Response } from 'express';
import * as gatewayService from '../services/gateway.service';
import { CreateGatewayInput, UpdateGatewayInput } from '../validators/gateway.validator';
/**
* GET /gateways
* Get all gateways with optional filters and pagination
* Query params: project_id, concentrator_id, status, page, limit, sortBy, sortOrder
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const { project_id, concentrator_id, status, page, limit, sortBy, sortOrder } = req.query;
const filters: gatewayService.GatewayFilters = {};
if (project_id) filters.project_id = project_id as string;
if (concentrator_id) filters.concentrator_id = concentrator_id as string;
if (status) filters.status = status as string;
const pagination: gatewayService.PaginationOptions = {
page: page ? parseInt(page as string, 10) : 1,
limit: limit ? parseInt(limit as string, 10) : 10,
sortBy: sortBy as string,
sortOrder: sortOrder as 'asc' | 'desc',
};
const result = await gatewayService.getAll(filters, pagination);
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching gateways:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch gateways',
});
}
}
/**
* GET /gateways/:id
* Get a single gateway by ID with device count
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const gateway = await gatewayService.getById(id);
if (!gateway) {
res.status(404).json({
success: false,
error: 'Gateway not found',
});
return;
}
res.status(200).json({
success: true,
data: gateway,
});
} catch (error) {
console.error('Error fetching gateway:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch gateway',
});
}
}
/**
* GET /gateways/:id/devices
* Get all devices for a specific gateway
*/
export async function getDevices(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
// First check if gateway exists
const gateway = await gatewayService.getById(id);
if (!gateway) {
res.status(404).json({
success: false,
error: 'Gateway not found',
});
return;
}
const devices = await gatewayService.getDevices(id);
res.status(200).json({
success: true,
data: devices,
});
} catch (error) {
console.error('Error fetching gateway devices:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch gateway devices',
});
}
}
/**
* POST /gateways
* Create a new gateway
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as CreateGatewayInput;
const gateway = await gatewayService.create(data);
res.status(201).json({
success: true,
data: gateway,
});
} catch (error) {
console.error('Error creating gateway:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('duplicate')) {
res.status(409).json({
success: false,
error: 'A gateway with this gateway_id already exists',
});
return;
}
// Check for foreign key violation
if (error instanceof Error && error.message.includes('foreign key')) {
res.status(400).json({
success: false,
error: 'Invalid project_id or concentrator_id: Reference does not exist',
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to create gateway',
});
}
}
/**
* PUT /gateways/:id
* Update an existing gateway
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as UpdateGatewayInput;
const gateway = await gatewayService.update(id, data);
if (!gateway) {
res.status(404).json({
success: false,
error: 'Gateway not found',
});
return;
}
res.status(200).json({
success: true,
data: gateway,
});
} catch (error) {
console.error('Error updating gateway:', error);
if (error instanceof Error && error.message.includes('duplicate')) {
res.status(409).json({
success: false,
error: 'A gateway with this gateway_id already exists',
});
return;
}
if (error instanceof Error && error.message.includes('foreign key')) {
res.status(400).json({
success: false,
error: 'Invalid project_id or concentrator_id: Reference does not exist',
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to update gateway',
});
}
}
/**
* DELETE /gateways/:id
* Delete a gateway
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await gatewayService.remove(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Gateway not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Gateway deleted successfully' },
});
} catch (error) {
console.error('Error deleting gateway:', error);
// Check for dependency error
if (error instanceof Error && error.message.includes('Cannot delete')) {
res.status(409).json({
success: false,
error: error.message,
});
return;
}
res.status(500).json({
success: false,
error: 'Failed to delete gateway',
});
}
}

View File

@@ -0,0 +1,284 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as meterService from '../services/meter.service';
import * as readingService from '../services/reading.service';
/**
* GET /meters
* List all meters with pagination and optional filtering
* Query params: page, pageSize, concentrator_id, project_id, status, type, search
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
const filters: meterService.MeterFilters = {};
if (req.query.concentrator_id) {
filters.concentrator_id = req.query.concentrator_id as string;
}
if (req.query.project_id) {
filters.project_id = req.query.project_id as string;
}
if (req.query.status) {
filters.status = req.query.status as string;
}
if (req.query.type) {
filters.type = req.query.type as string;
}
if (req.query.search) {
filters.search = req.query.search as string;
}
const result = await meterService.getAll(filters, { page, pageSize });
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching meters:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch meters',
});
}
}
/**
* GET /meters/:id
* Get a single meter by ID with device info
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const meter = await meterService.getById(id);
if (!meter) {
res.status(404).json({
success: false,
error: 'Meter not found',
});
return;
}
res.status(200).json({
success: true,
data: meter,
});
} catch (error) {
console.error('Error fetching meter:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch meter',
});
}
}
/**
* POST /meters
* Create a new meter
* Requires authentication
*/
export async function create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: 'Authentication required',
});
return;
}
const data = req.body as meterService.CreateMeterInput;
const meter = await meterService.create(data);
res.status(201).json({
success: true,
data: meter,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create meter';
// Handle unique constraint violation
if (message.includes('duplicate') || message.includes('unique')) {
res.status(409).json({
success: false,
error: 'A meter with this serial number already exists',
});
return;
}
// Handle foreign key constraint violation
if (message.includes('foreign key') || message.includes('violates')) {
res.status(400).json({
success: false,
error: 'Invalid concentrator_id reference',
});
return;
}
console.error('Error creating meter:', error);
res.status(500).json({
success: false,
error: 'Failed to create meter',
});
}
}
/**
* PUT /meters/:id
* Update an existing meter
* Requires authentication
*/
export async function update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as meterService.UpdateMeterInput;
const meter = await meterService.update(id, data);
if (!meter) {
res.status(404).json({
success: false,
error: 'Meter not found',
});
return;
}
res.status(200).json({
success: true,
data: meter,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update meter';
// Handle unique constraint violation
if (message.includes('duplicate') || message.includes('unique')) {
res.status(409).json({
success: false,
error: 'A meter with this serial number already exists',
});
return;
}
// Handle foreign key constraint violation
if (message.includes('foreign key') || message.includes('violates')) {
res.status(400).json({
success: false,
error: 'Invalid concentrator_id reference',
});
return;
}
console.error('Error updating meter:', error);
res.status(500).json({
success: false,
error: 'Failed to update meter',
});
}
}
/**
* DELETE /meters/:id
* Delete a meter
* Requires admin role
*/
export async function deleteMeter(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
// First check if meter exists
const meter = await meterService.getById(id);
if (!meter) {
res.status(404).json({
success: false,
error: 'Meter not found',
});
return;
}
const deleted = await meterService.deleteMeter(id);
if (!deleted) {
res.status(500).json({
success: false,
error: 'Failed to delete meter',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Meter deleted successfully' },
});
} catch (error) {
console.error('Error deleting meter:', error);
res.status(500).json({
success: false,
error: 'Failed to delete meter',
});
}
}
/**
* GET /meters/:id/readings
* Get meter readings history with optional date range filter
* Query params: start_date, end_date, page, pageSize
*/
export async function getReadings(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
// First check if meter exists
const meter = await meterService.getById(id);
if (!meter) {
res.status(404).json({
success: false,
error: 'Meter not found',
});
return;
}
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 100);
const filters: readingService.ReadingFilters = {
meter_id: id,
};
if (req.query.start_date) {
filters.start_date = req.query.start_date as string;
}
if (req.query.end_date) {
filters.end_date = req.query.end_date as string;
}
const result = await readingService.getAll(filters, { page, pageSize });
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching meter readings:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch meter readings',
});
}
}

View File

@@ -0,0 +1,227 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as projectService from '../services/project.service';
import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../validators/project.validator';
/**
* GET /projects
* List all projects with pagination and optional filtering
* Query params: page, pageSize, status, area_name, search
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
const filters: projectService.ProjectFilters = {};
if (req.query.status) {
filters.status = req.query.status as ProjectStatusType;
}
if (req.query.area_name) {
filters.area_name = req.query.area_name as string;
}
if (req.query.search) {
filters.search = req.query.search as string;
}
const result = await projectService.getAll(filters, { page, pageSize });
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching projects:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch projects',
});
}
}
/**
* GET /projects/:id
* Get a single project by ID
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const project = await projectService.getById(id);
if (!project) {
res.status(404).json({
success: false,
error: 'Project not found',
});
return;
}
res.status(200).json({
success: true,
data: project,
});
} catch (error) {
console.error('Error fetching project:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch project',
});
}
}
/**
* POST /projects
* Create a new project
* Requires authentication
*/
export async function create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: 'Authentication required',
});
return;
}
const data = req.body as CreateProjectInput;
const project = await projectService.create(data, userId);
res.status(201).json({
success: true,
data: project,
});
} catch (error) {
console.error('Error creating project:', error);
res.status(500).json({
success: false,
error: 'Failed to create project',
});
}
}
/**
* PUT /projects/:id
* Update an existing project
* Requires authentication
*/
export async function update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as UpdateProjectInput;
const project = await projectService.update(id, data);
if (!project) {
res.status(404).json({
success: false,
error: 'Project not found',
});
return;
}
res.status(200).json({
success: true,
data: project,
});
} catch (error) {
console.error('Error updating project:', error);
res.status(500).json({
success: false,
error: 'Failed to update project',
});
}
}
/**
* DELETE /projects/:id
* Delete a project
* Requires admin role
*/
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
// First check if project exists
const project = await projectService.getById(id);
if (!project) {
res.status(404).json({
success: false,
error: 'Project not found',
});
return;
}
const deleted = await projectService.deleteProject(id);
if (!deleted) {
res.status(500).json({
success: false,
error: 'Failed to delete project',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Project deleted successfully' },
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete project';
// Handle dependency error
if (message.includes('Cannot delete project')) {
res.status(409).json({
success: false,
error: message,
});
return;
}
console.error('Error deleting project:', error);
res.status(500).json({
success: false,
error: 'Failed to delete project',
});
}
}
/**
* GET /projects/:id/stats
* Get project statistics
*/
export async function getStats(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const stats = await projectService.getStats(id);
if (!stats) {
res.status(404).json({
success: false,
error: 'Project not found',
});
return;
}
res.status(200).json({
success: true,
data: stats,
});
} catch (error) {
console.error('Error fetching project stats:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch project statistics',
});
}
}

View File

@@ -0,0 +1,158 @@
import { Request, Response } from 'express';
import * as readingService from '../services/reading.service';
/**
* GET /readings
* List all readings with pagination and filtering
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const {
page = '1',
pageSize = '50',
meter_id,
concentrator_id,
project_id,
start_date,
end_date,
reading_type,
} = req.query;
const filters: readingService.ReadingFilters = {};
if (meter_id) filters.meter_id = meter_id as string;
if (concentrator_id) filters.concentrator_id = concentrator_id as string;
if (project_id) filters.project_id = project_id as string;
if (start_date) filters.start_date = start_date as string;
if (end_date) filters.end_date = end_date as string;
if (reading_type) filters.reading_type = reading_type as string;
const pagination = {
page: parseInt(page as string, 10),
pageSize: Math.min(parseInt(pageSize as string, 10), 100), // Max 100 per page
};
const result = await readingService.getAll(filters, pagination);
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching readings:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
}
/**
* GET /readings/:id
* Get a single reading by ID
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const reading = await readingService.getById(id);
if (!reading) {
res.status(404).json({
success: false,
error: 'Reading not found',
});
return;
}
res.status(200).json({
success: true,
data: reading,
});
} catch (error) {
console.error('Error fetching reading:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
}
/**
* POST /readings
* Create a new reading
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as readingService.CreateReadingInput;
const reading = await readingService.create(data);
res.status(201).json({
success: true,
data: reading,
});
} catch (error) {
console.error('Error creating reading:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
}
/**
* DELETE /readings/:id
* Delete a reading
*/
export async function deleteReading(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await readingService.deleteReading(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Reading not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Reading deleted successfully' },
});
} catch (error) {
console.error('Error deleting reading:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
}
/**
* GET /readings/summary
* Get consumption summary statistics
*/
export async function getSummary(req: Request, res: Response): Promise<void> {
try {
const { project_id } = req.query;
const summary = await readingService.getConsumptionSummary(
project_id as string | undefined
);
res.status(200).json({
success: true,
data: summary,
});
} catch (error) {
console.error('Error fetching summary:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
}

View File

@@ -0,0 +1,222 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as roleService from '../services/role.service';
import { CreateRoleInput, UpdateRoleInput } from '../validators/role.validator';
/**
* GET /roles
* List all roles (all authenticated users)
*/
export async function getAllRoles(
_req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const roles = await roleService.getAll();
res.status(200).json({
success: true,
message: 'Roles retrieved successfully',
data: roles,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to retrieve roles';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* GET /roles/:id
* Get a single role by ID with user count (all authenticated users)
*/
export async function getRoleById(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const roleId = parseInt(req.params.id, 10);
if (isNaN(roleId)) {
res.status(400).json({
success: false,
error: 'Invalid role ID',
});
return;
}
const role = await roleService.getById(roleId);
if (!role) {
res.status(404).json({
success: false,
error: 'Role not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Role retrieved successfully',
data: role,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to retrieve role';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* POST /roles
* Create a new role (admin only)
*/
export async function createRole(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const data = req.body as CreateRoleInput;
const role = await roleService.create({
name: data.name,
description: data.description,
permissions: data.permissions as Record<string, unknown> | undefined,
});
res.status(201).json({
success: true,
message: 'Role created successfully',
data: role,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create role';
if (message === 'Role name already exists') {
res.status(409).json({
success: false,
error: message,
});
return;
}
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* PUT /roles/:id
* Update a role (admin only)
*/
export async function updateRole(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const roleId = parseInt(req.params.id, 10);
if (isNaN(roleId)) {
res.status(400).json({
success: false,
error: 'Invalid role ID',
});
return;
}
const data = req.body as UpdateRoleInput;
const role = await roleService.update(roleId, {
name: data.name,
description: data.description,
permissions: data.permissions as Record<string, unknown> | undefined,
});
if (!role) {
res.status(404).json({
success: false,
error: 'Role not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Role updated successfully',
data: role,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update role';
if (message === 'Role name already exists') {
res.status(409).json({
success: false,
error: message,
});
return;
}
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* DELETE /roles/:id
* Delete a role (admin only, only if no users assigned)
*/
export async function deleteRole(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const roleId = parseInt(req.params.id, 10);
if (isNaN(roleId)) {
res.status(400).json({
success: false,
error: 'Invalid role ID',
});
return;
}
const deleted = await roleService.deleteRole(roleId);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Role not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Role deleted successfully',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete role';
// Handle case where users are assigned to the role
if (message.includes('Cannot delete role')) {
res.status(409).json({
success: false,
error: message,
});
return;
}
res.status(500).json({
success: false,
error: message,
});
}
}

View File

@@ -0,0 +1,194 @@
import { Request, Response } from 'express';
import logger from '../utils/logger';
import * as ttsWebhookService from '../services/tts/ttsWebhook.service';
import {
TtsUplinkPayload,
TtsJoinPayload,
TtsDownlinkAckPayload,
} from '../validators/tts.validator';
/**
* Extended request interface for TTS webhooks
*/
export interface TtsWebhookRequest extends Request {
ttsVerified?: boolean;
ttsApiKey?: string;
}
/**
* POST /api/webhooks/tts/uplink
* Handle uplink webhook from The Things Stack
*
* This endpoint receives uplink messages when devices send data.
* The payload is validated, logged, decoded, and used to create meter readings.
*/
export async function handleUplink(req: TtsWebhookRequest, res: Response): Promise<void> {
try {
const payload = req.body as TtsUplinkPayload;
logger.info('Received TTS uplink webhook', {
devEui: payload.end_device_ids.dev_eui,
deviceId: payload.end_device_ids.device_id,
fPort: payload.uplink_message.f_port,
verified: req.ttsVerified,
});
const result = await ttsWebhookService.processUplink(payload);
if (result.success) {
res.status(200).json({
success: true,
message: 'Uplink processed successfully',
data: {
logId: result.logId,
deviceId: result.deviceId,
meterId: result.meterId,
readingId: result.readingId,
readingValue: result.decodedPayload?.readingValue,
},
});
} else {
// We still return 200 to TTS to prevent retries for known issues
// (device not found, decoding failed, etc.)
res.status(200).json({
success: false,
message: result.error || 'Failed to process uplink',
data: {
logId: result.logId,
deviceId: result.deviceId,
},
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error handling TTS uplink webhook', { error: errorMessage });
// Return 500 to trigger TTS retry mechanism for unexpected errors
res.status(500).json({
success: false,
error: 'Internal server error',
message: errorMessage,
});
}
}
/**
* POST /api/webhooks/tts/join
* Handle join webhook from The Things Stack
*
* This endpoint receives join accept messages when devices join the network.
* Updates device status to 'JOINED'.
*/
export async function handleJoin(req: TtsWebhookRequest, res: Response): Promise<void> {
try {
const payload = req.body as TtsJoinPayload;
logger.info('Received TTS join webhook', {
devEui: payload.end_device_ids.dev_eui,
deviceId: payload.end_device_ids.device_id,
verified: req.ttsVerified,
});
const result = await ttsWebhookService.processJoin(payload);
if (result.success) {
res.status(200).json({
success: true,
message: 'Join event processed successfully',
data: {
deviceId: result.deviceId,
},
});
} else {
// Return 200 even on failure to prevent unnecessary retries
res.status(200).json({
success: false,
message: result.error || 'Failed to process join event',
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error handling TTS join webhook', { error: errorMessage });
res.status(500).json({
success: false,
error: 'Internal server error',
message: errorMessage,
});
}
}
/**
* POST /api/webhooks/tts/downlink/ack
* Handle downlink acknowledgment webhook from The Things Stack
*
* This endpoint receives confirmations when downlink messages are
* acknowledged by devices, sent, failed, or queued.
*/
export async function handleDownlinkAck(req: TtsWebhookRequest, res: Response): Promise<void> {
try {
const payload = req.body as TtsDownlinkAckPayload;
// Determine event type for logging
let eventType = 'ack';
if (payload.downlink_sent) eventType = 'sent';
if (payload.downlink_failed) eventType = 'failed';
if (payload.downlink_queued) eventType = 'queued';
logger.info('Received TTS downlink webhook', {
devEui: payload.end_device_ids.dev_eui,
deviceId: payload.end_device_ids.device_id,
eventType,
verified: req.ttsVerified,
});
const result = await ttsWebhookService.processDownlinkAck(payload);
if (result.success) {
res.status(200).json({
success: true,
message: 'Downlink event processed successfully',
data: {
logId: result.logId,
deviceId: result.deviceId,
eventType,
},
});
} else {
res.status(200).json({
success: false,
message: result.error || 'Failed to process downlink event',
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error handling TTS downlink webhook', { error: errorMessage });
res.status(500).json({
success: false,
error: 'Internal server error',
message: errorMessage,
});
}
}
/**
* GET /api/webhooks/tts/health
* Health check endpoint for TTS webhooks
*
* Can be used by TTS or monitoring systems to verify the webhook endpoint is available.
*/
export async function healthCheck(_req: Request, res: Response): Promise<void> {
res.status(200).json({
success: true,
message: 'TTS webhook endpoint is healthy',
timestamp: new Date().toISOString(),
});
}
export default {
handleUplink,
handleJoin,
handleDownlinkAck,
healthCheck,
};

View File

@@ -0,0 +1,352 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as userService from '../services/user.service';
import {
CreateUserInput,
UpdateUserInput,
ChangePasswordInput,
} from '../validators/user.validator';
/**
* GET /users
* List all users (admin only)
* Supports filtering by role_id, is_active, and search
* Supports pagination with page, limit, sortBy, sortOrder
*/
export async function getAllUsers(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
// Parse query parameters for filters
const filters: userService.UserFilter = {};
if (req.query.role_id) {
filters.role_id = parseInt(req.query.role_id as string, 10);
}
if (req.query.is_active !== undefined) {
filters.is_active = req.query.is_active === 'true';
}
if (req.query.search) {
filters.search = req.query.search as string;
}
// Parse pagination parameters
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: parseInt(req.query.limit as string, 10) || 10,
sortBy: (req.query.sortBy as string) || 'created_at',
sortOrder: (req.query.sortOrder as 'asc' | 'desc') || 'desc',
};
const result = await userService.getAll(filters, pagination);
res.status(200).json({
success: true,
message: 'Users retrieved successfully',
data: result.users,
pagination: result.pagination,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to retrieve users';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* GET /users/:id
* Get a single user by ID (admin or self)
*/
export async function getUserById(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
});
return;
}
// Check if user is admin or requesting their own data
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId.toString();
if (!isAdmin && !isSelf) {
res.status(403).json({
success: false,
error: 'Insufficient permissions',
});
return;
}
const user = await userService.getById(userId);
if (!user) {
res.status(404).json({
success: false,
error: 'User not found',
});
return;
}
res.status(200).json({
success: true,
message: 'User retrieved successfully',
data: user,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to retrieve user';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* POST /users
* Create a new user (admin only)
*/
export async function createUser(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const data = req.body as CreateUserInput;
const user = await userService.create({
email: data.email,
password: data.password,
first_name: data.first_name,
last_name: data.last_name,
role_id: data.role_id,
is_active: data.is_active,
});
res.status(201).json({
success: true,
message: 'User created successfully',
data: user,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create user';
if (message === 'Email already in use') {
res.status(409).json({
success: false,
error: message,
});
return;
}
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* PUT /users/:id
* Update a user (admin can update all fields, regular users can only update limited fields on self)
*/
export async function updateUser(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
});
return;
}
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId.toString();
if (!isAdmin && !isSelf) {
res.status(403).json({
success: false,
error: 'Insufficient permissions',
});
return;
}
const data = req.body as UpdateUserInput;
// Non-admin users can only update their own profile fields (not role_id or is_active)
if (!isAdmin) {
if (data.role_id !== undefined || data.is_active !== undefined) {
res.status(403).json({
success: false,
error: 'You can only update your profile information',
});
return;
}
}
const user = await userService.update(userId, data);
if (!user) {
res.status(404).json({
success: false,
error: 'User not found',
});
return;
}
res.status(200).json({
success: true,
message: 'User updated successfully',
data: user,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update user';
if (message === 'Email already in use') {
res.status(409).json({
success: false,
error: message,
});
return;
}
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* DELETE /users/:id
* Deactivate a user (soft delete, admin only)
*/
export async function deleteUser(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
});
return;
}
// Prevent admin from deleting themselves
if (req.user?.id === userId.toString()) {
res.status(400).json({
success: false,
error: 'Cannot deactivate your own account',
});
return;
}
const deleted = await userService.deleteUser(userId);
if (!deleted) {
res.status(404).json({
success: false,
error: 'User not found',
});
return;
}
res.status(200).json({
success: true,
message: 'User deactivated successfully',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to deactivate user';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* PUT /users/:id/password
* Change user password (self only)
*/
export async function changePassword(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
});
return;
}
// Only allow users to change their own password
if (req.user?.id !== userId.toString()) {
res.status(403).json({
success: false,
error: 'You can only change your own password',
});
return;
}
const data = req.body as ChangePasswordInput;
await userService.changePassword(
userId,
data.current_password,
data.new_password
);
res.status(200).json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to change password';
if (message === 'Current password is incorrect') {
res.status(401).json({
success: false,
error: message,
});
return;
}
if (message === 'User not found') {
res.status(404).json({
success: false,
error: message,
});
return;
}
res.status(500).json({
success: false,
error: message,
});
}
}