Compare commits

..

3 Commits

Author SHA1 Message Date
Exteban08
6c7d448b2f Fix: Corregir pantalla blanca y mejorar carga masiva
- Fix error .toFixed() con valores DECIMAL de PostgreSQL (string vs number)
- Fix modal de carga masiva que se cerraba sin mostrar resultados
- Validar fechas antes de insertar en BD (evita error con "Installed")
- Agregar mapeos de columnas comunes (device_status, device_name, etc.)
- Normalizar valores de status (Installed -> ACTIVE, New_LoRa -> ACTIVE)
- Actualizar documentación del proyecto

Archivos modificados:
- src/pages/meters/MetersTable.tsx
- src/pages/consumption/ConsumptionPage.tsx
- src/pages/meters/MeterPage.tsx
- water-api/src/services/bulk-upload.service.ts
- ESTADO_ACTUAL.md
- CAMBIOS_SESION.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 23:13:48 +00:00
Exteban08
ab97987c6a Agregar carga masiva de lecturas y corregir manejo de respuestas paginadas
- Implementar carga masiva de lecturas via Excel (backend y frontend)
- Corregir cliente API para manejar respuestas con paginación
- Eliminar referencias a device_id (columna inexistente)
- Cambiar areaName por meterLocation en lecturas
- Actualizar fetchProjects y fetchConcentrators para paginación
- Agregar documentación del estado actual y cambios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:23:41 +00:00
Exteban08
c81a18987f 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>
2026-01-23 10:13:26 +00:00
95 changed files with 15290 additions and 1860 deletions

171
CAMBIOS_SESION.md Normal file
View File

@@ -0,0 +1,171 @@
# Cambios Realizados - Sesión 2026-01-23
## Resumen
Corrección de errores críticos que causaban pantalla blanca y mejoras en el sistema de carga masiva.
---
## Problema 1: Pantalla Blanca en Water Meters y Consumo
### Síntoma
Al navegar a "Water Meters" o "Consumo", la página se quedaba en blanco.
### Causa
PostgreSQL devuelve valores DECIMAL como strings (ej: `"300.0000"`). El código llamaba `.toFixed()` directamente sobre estos strings, pero `.toFixed()` es un método de números, no de strings.
### Solución
Convertir los valores a número con `Number()` antes de llamar `.toFixed()`.
### Archivos Modificados
**`src/pages/meters/MetersTable.tsx` (línea 75)**
```typescript
// ANTES:
r.lastReadingValue?.toFixed(2)
// DESPUÉS:
r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-"
```
**`src/pages/consumption/ConsumptionPage.tsx` (líneas 133, 213, 432)**
```typescript
// ANTES:
r.readingValue.toFixed(2)
summary?.avgReading.toFixed(1)
reading.readingValue.toFixed(2)
// DESPUÉS:
Number(r.readingValue).toFixed(2)
summary?.avgReading != null ? Number(summary.avgReading).toFixed(1) : "0"
Number(reading.readingValue).toFixed(2)
```
---
## Problema 2: Modal de Carga Masiva se Cerraba sin Mostrar Resultados
### Síntoma
Al subir un archivo Excel para carga masiva, el modal se cerraba inmediatamente sin mostrar cuántos registros se insertaron o qué errores hubo.
### Causa
El callback `onSuccess` cerraba el modal automáticamente:
```typescript
onSuccess={() => {
m.loadMeters();
setShowBulkUpload(false); // ← Cerraba antes de ver resultados
}}
```
### Solución
Separar la recarga de datos del cierre del modal. Ahora el modal solo se cierra cuando el usuario hace clic en "Cerrar".
### Archivo Modificado
**`src/pages/meters/MeterPage.tsx` (líneas 332-340)**
```typescript
// ANTES:
<MetersBulkUploadModal
onClose={() => setShowBulkUpload(false)}
onSuccess={() => {
m.loadMeters();
setShowBulkUpload(false);
}}
/>
// DESPUÉS:
<MetersBulkUploadModal
onClose={() => {
m.loadMeters();
setShowBulkUpload(false);
}}
onSuccess={() => {
m.loadMeters();
}}
/>
```
---
## Problema 3: Error de Fecha Inválida en Carga Masiva
### Síntoma
Al subir medidores, aparecía el error:
```
Fila X: invalid input syntax for type date: "Installed"
```
### Causa
El archivo Excel tenía columnas con valores como "Installed" o "New_LoRa" que el sistema interpretaba como fechas porque no estaban mapeadas correctamente.
### Solución
1. **Validar fechas**: Verificar que `installation_date` sea realmente una fecha válida antes de usarla.
2. **Más mapeos de columnas**: Agregar mapeos para columnas comunes como `device_status`, `device_name`, etc.
3. **Normalizar status**: Convertir valores como "Installed", "New_LoRa" a "ACTIVE".
### Archivo Modificado
**`water-api/src/services/bulk-upload.service.ts`**
Validación de fechas (líneas 183-195):
```typescript
let installationDate: string | undefined = undefined;
if (row.installation_date) {
const dateStr = String(row.installation_date).trim();
if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(dateStr) || /^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}/.test(dateStr)) {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
installationDate = parsed.toISOString().split('T')[0];
}
}
}
```
Mapeos de columnas adicionales (líneas 65-90):
```typescript
const mappings: Record<string, string> = {
// Serial number
'device_s/n': 'serial_number',
'device_sn': 'serial_number',
// Name
'device_name': 'name',
'meter_name': 'name',
// Status
'device_status': 'status',
// ... más mapeos
};
```
Normalización de status (líneas 210-225):
```typescript
const statusMappings: Record<string, string> = {
'INSTALLED': 'ACTIVE',
'NEW_LORA': 'ACTIVE',
'NEW': 'ACTIVE',
'ENABLED': 'ACTIVE',
'DISABLED': 'INACTIVE',
// ...
};
```
---
## Archivos Modificados en Esta Sesión
| Archivo | Cambio |
|---------|--------|
| `src/pages/meters/MetersTable.tsx` | Fix `.toFixed()` en lastReadingValue |
| `src/pages/consumption/ConsumptionPage.tsx` | Fix `.toFixed()` en readingValue y avgReading |
| `src/pages/meters/MeterPage.tsx` | Fix modal de carga masiva |
| `water-api/src/services/bulk-upload.service.ts` | Validación de fechas, mapeos de columnas, normalización de status |
| `ESTADO_ACTUAL.md` | Documentación actualizada |
| `CAMBIOS_SESION.md` | Este archivo |
---
## Verificación
1. ✅ La página de Water Meters carga correctamente
2. ✅ La página de Consumo carga correctamente
3. ✅ El modal de carga masiva muestra resultados
4. ✅ Errores de carga masiva se muestran claramente
5. ✅ Valores como "Installed" no causan error de fecha

249
ESTADO_ACTUAL.md Normal file
View File

@@ -0,0 +1,249 @@
# Estado Actual del Proyecto Water Project GRH
**Fecha:** 2026-01-23
**Última actualización:** Corrección de errores y mejoras en carga masiva
---
## Resumen del Proyecto
Sistema de gestión de medidores de agua con:
- **Frontend:** React + TypeScript + Vite (puerto 5173)
- **Backend:** Node.js + Express + TypeScript (puerto 3000)
- **Base de datos:** PostgreSQL
### Jerarquía de datos:
```
Projects → Concentrators → Meters → Readings
```
---
## Arquitectura del Sistema
```
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React) │
│ http://localhost:5173 │
├─────────────────────────────────────────────────────────────┤
│ - React 18 + TypeScript + Vite │
│ - Tailwind CSS + Material-UI │
│ - Recharts para gráficos │
│ - Cliente API con JWT automático │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BACKEND (Node.js) │
│ http://localhost:3000 │
├─────────────────────────────────────────────────────────────┤
│ - Express + TypeScript │
│ - Autenticación JWT con refresh tokens │
│ - CRUD completo para todas las entidades │
│ - Carga masiva via Excel (xlsx) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BASE DE DATOS │
│ PostgreSQL │
├─────────────────────────────────────────────────────────────┤
│ Tablas: users, roles, projects, concentrators, │
│ meters, meter_readings, refresh_tokens │
└─────────────────────────────────────────────────────────────┘
```
---
## Funcionalidades Implementadas
### 1. Autenticación
- Login con JWT + refresh tokens
- Manejo automático de renovación de tokens
- Roles: ADMIN, USER
### 2. Gestión de Proyectos
- CRUD completo
- Estados: ACTIVE/INACTIVE
### 3. Gestión de Concentradores
- CRUD completo
- Vinculados a proyectos
- Tipos: Gateway LoRa/LoRaWAN
### 4. Gestión de Medidores
- CRUD completo
- Tipos: LORA, LORAWAN, GRANDES
- Estados: ACTIVE, INACTIVE, MAINTENANCE, FAULTY, REPLACED
- **Carga masiva via Excel**
- Última lectura visible en tabla
### 5. Gestión de Lecturas (Consumo)
- CRUD completo
- Tipos: AUTOMATIC, MANUAL, SCHEDULED
- **Carga masiva via Excel**
- Filtros por proyecto, fecha
- Exportación a CSV
- Indicadores de batería y señal
### 6. Dashboard
- KPIs: Total lecturas, medidores activos, consumo promedio
- Gráficos por proyecto
- Últimas alertas
---
## Carga Masiva
### Medidores (Excel)
Columnas requeridas:
- `serial_number` - Número de serie del medidor (único)
- `name` - Nombre del medidor
- `concentrator_serial` - Serial del concentrador existente
Columnas opcionales:
- `meter_id` - ID del medidor
- `location` - Ubicación
- `type` - LORA, LORAWAN, GRANDES (default: LORA)
- `status` - ACTIVE, INACTIVE, etc. (default: ACTIVE)
- `installation_date` - Fecha de instalación (YYYY-MM-DD)
### Lecturas (Excel)
Columnas requeridas:
- `meter_serial` - Serial del medidor existente
- `reading_value` - Valor de la lectura
Columnas opcionales:
- `reading_type` - AUTOMATIC, MANUAL, SCHEDULED (default: MANUAL)
- `received_at` - Fecha/hora (default: ahora)
- `battery_level` - Nivel de batería (%)
- `signal_strength` - Intensidad de señal (dBm)
---
## Credenciales
### Usuario Admin
- **Nombre:** Ivan Alcaraz
- **Email:** ialcarazsalazar@consultoria-as.com
- **Password:** Aasi940812
---
## Datos Actuales en BD
### Proyectos
- ADAMANT
- OLE
- LUZIA
- ATELIER
### Concentradores
| Serial | Nombre | Proyecto |
|--------|--------|----------|
| 2024072612 | Adamant | ADAMANT |
| 2024030601 | OLE | OLE |
| 2024030402 | LUZIA | LUZIA |
| 2024072602 | ATELIER | ATELIER |
### Medidores
- ADAMANT: 201 medidores
- OLE: 5 medidores
---
## Correcciones Recientes (2026-01-23)
### 1. Error `.toFixed()` con valores string
**Problema:** PostgreSQL devuelve DECIMAL como string, causando error al llamar `.toFixed()`.
**Solución:** Convertir a número con `Number()` antes de llamar `.toFixed()`.
**Archivos:**
- `src/pages/meters/MetersTable.tsx:75`
- `src/pages/consumption/ConsumptionPage.tsx:133, 213, 432`
### 2. Modal de carga masiva se cerraba sin mostrar resultados
**Problema:** El modal se cerraba automáticamente después de la carga.
**Solución:** El modal ahora permanece abierto para mostrar resultados y errores.
**Archivo:** `src/pages/meters/MeterPage.tsx:332-340`
### 3. Validación de fechas en carga masiva
**Problema:** Valores como "Installed" en columnas no mapeadas causaban error de fecha inválida.
**Solución:** Validar que `installation_date` sea realmente una fecha antes de insertarla.
**Archivo:** `water-api/src/services/bulk-upload.service.ts:183-195`
### 4. Mapeo de columnas mejorado
**Mejora:** Agregados más mapeos de columnas comunes (device_status, device_name, etc.)
**Archivo:** `water-api/src/services/bulk-upload.service.ts:65-90`
### 5. Normalización de status
**Mejora:** Valores como "Installed", "New_LoRa" se convierten automáticamente a "ACTIVE".
**Archivo:** `water-api/src/services/bulk-upload.service.ts:210-225`
---
## Comandos Útiles
```bash
# Iniciar backend
cd /home/GRH/water-project/water-api
npm run dev
# Iniciar frontend
cd /home/GRH/water-project
npm run dev
# Compilar backend
cd /home/GRH/water-project/water-api
npm run build
# Ver logs del backend
tail -f /tmp/water-api.log
```
---
## Estructura de Archivos
```
water-project/
├── src/ # Frontend React
│ ├── api/ # Cliente API
│ │ ├── client.ts # Cliente HTTP con JWT
│ │ ├── meters.ts # API de medidores
│ │ ├── readings.ts # API de lecturas
│ │ ├── projects.ts # API de proyectos
│ │ └── concentrators.ts # API de concentradores
│ ├── pages/ # Páginas
│ │ ├── meters/ # Módulo de medidores
│ │ │ ├── MeterPage.tsx
│ │ │ ├── MetersTable.tsx
│ │ │ ├── MetersModal.tsx
│ │ │ ├── MetersSidebar.tsx
│ │ │ ├── MetersBulkUploadModal.tsx
│ │ │ └── useMeters.ts
│ │ ├── consumption/ # Módulo de consumo
│ │ │ ├── ConsumptionPage.tsx
│ │ │ └── ReadingsBulkUploadModal.tsx
│ │ └── ...
│ └── components/ # Componentes reutilizables
└── water-api/ # Backend Node.js
├── src/
│ ├── controllers/ # Controladores REST
│ ├── services/ # Lógica de negocio
│ │ ├── bulk-upload.service.ts
│ │ └── ...
│ ├── routes/ # Definición de rutas
│ ├── middleware/ # Middlewares (auth, etc.)
│ └── config/ # Configuración (DB, etc.)
└── sql/ # Scripts SQL
```
---
## Próximos Pasos Sugeridos
1. **Integración TTS** - Webhooks para The Things Stack
2. **Alertas automáticas** - Notificaciones por consumo anormal
3. **Reportes** - Generación de reportes PDF
4. **Despliegue** - Configurar para producción

View File

@@ -8,6 +8,7 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage";
import ProjectsPage from "./pages/projects/ProjectsPage";
import UsersPage from "./pages/UsersPage";
import RolesPage from "./pages/RolesPage";
import ConsumptionPage from "./pages/consumption/ConsumptionPage";
import ProfileModal from "./components/layout/common/ProfileModal";
import { updateMyProfile } from "./api/me";
@@ -18,7 +19,15 @@ import SettingsModal, {
import LoginPage from "./pages/LoginPage";
// ✅ NUEVO
// Auth imports
import {
isAuthenticated,
getMe,
logout as authLogout,
clearAuth,
type AuthUser,
} from "./api/auth";
import ConfirmModal from "./components/layout/common/ConfirmModal";
import Watermark from "./components/layout/common/Watermark";
@@ -27,28 +36,59 @@ export type Page =
| "projects"
| "meters"
| "concentrators"
| "consumption"
| "users"
| "roles";
const AUTH_KEY = "grh_auth";
export default function App() {
const [isAuth, setIsAuth] = useState<boolean>(() => {
return Boolean(localStorage.getItem(AUTH_KEY));
});
const [isAuth, setIsAuth] = useState<boolean>(false);
const [user, setUser] = useState<AuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const handleLogin = (payload?: { token?: string }) => {
localStorage.setItem(
AUTH_KEY,
JSON.stringify({ token: payload?.token ?? "demo", ts: Date.now() })
);
// Check authentication on mount
useEffect(() => {
const checkAuth = async () => {
if (isAuthenticated()) {
try {
const userData = await getMe();
setUser(userData);
setIsAuth(true);
} catch {
clearAuth();
setIsAuth(false);
setUser(null);
}
}
setAuthLoading(false);
};
checkAuth();
}, []);
const handleLogin = () => {
// After successful login, fetch user data
const fetchUser = async () => {
try {
const userData = await getMe();
setUser(userData);
setIsAuth(true);
} catch {
clearAuth();
setIsAuth(false);
}
};
fetchUser();
};
const handleLogout = () => {
localStorage.removeItem(AUTH_KEY);
const handleLogout = async () => {
try {
await authLogout();
} catch {
// Ignore logout errors
}
clearAuth();
setUser(null);
setIsAuth(false);
// opcional: reset de navegación
// Reset navigation
setPage("home");
setSubPage("default");
setSelectedProject("");
@@ -65,13 +105,6 @@ export default function App() {
const [profileOpen, setProfileOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [user, setUser] = useState({
name: "CESPT Admin",
email: "admin@cespt.gob.mx",
avatarUrl: null as string | null,
organismName: "CESPT",
});
const [settingsOpen, setSettingsOpen] = useState(false);
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
@@ -84,7 +117,7 @@ export default function App() {
const handleUploadAvatar = async (file: File) => {
const base64 = await fileToBase64(file);
localStorage.setItem("mock_avatar", base64);
setUser((prev) => ({ ...prev, avatarUrl: base64 }));
setUser((prev) => prev ? { ...prev, avatar_url: base64 } : null);
};
function fileToBase64(file: File) {
@@ -101,18 +134,17 @@ export default function App() {
email: string;
organismName?: string;
}) => {
if (!user) return;
setSavingProfile(true);
try {
const updated = await updateMyProfile(next);
setUser((prev) => ({
setUser((prev) => prev ? ({
...prev,
name: updated.name ?? next.name ?? prev.name,
email: updated.email ?? next.email ?? prev.email,
avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
organismName:
updated.organismName ?? next.organismName ?? prev.organismName,
}));
avatar_url: updated.avatarUrl ?? prev.avatar_url,
}) : null);
setProfileOpen(false);
} finally {
@@ -141,6 +173,8 @@ export default function App() {
return <MetersPage selectedProject={selectedProject} />;
case "concentrators":
return <ConcentratorsPage />;
case "consumption":
return <ConsumptionPage />;
case "users":
return <UsersPage />;
case "roles":
@@ -159,6 +193,15 @@ export default function App() {
}
};
// Show loading while checking authentication
if (authLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-slate-50">
<div className="text-slate-500">Cargando...</div>
</div>
);
}
if (!isAuth) {
return <LoginPage onSuccess={handleLogin} />;
}
@@ -186,11 +229,10 @@ export default function App() {
page={page}
subPage={subPage}
setSubPage={setSubPage}
userName={user.name}
userEmail={user.email}
avatarUrl={user.avatarUrl}
userName={user?.name ?? "Usuario"}
userEmail={user?.email ?? ""}
avatarUrl={user?.avatar_url ?? null}
onOpenProfile={() => setProfileOpen(true)}
// ✅ en vez de cerrar, abrimos confirm modal
onRequestLogout={() => setLogoutOpen(true)}
/>
</div>
@@ -212,11 +254,11 @@ export default function App() {
<ProfileModal
open={profileOpen}
loading={savingProfile}
avatarUrl={user.avatarUrl}
avatarUrl={user?.avatar_url ?? null}
initial={{
name: user.name,
email: user.email,
organismName: user.organismName,
name: user?.name ?? "",
email: user?.email ?? "",
organismName: "",
}}
onClose={() => setProfileOpen(false)}
onSave={handleSaveProfile}
@@ -236,7 +278,7 @@ export default function App() {
onConfirm={async () => {
setLoggingOut(true);
try {
handleLogout();
await handleLogout();
setLogoutOpen(false);
} finally {
setLoggingOut(false);

331
src/api/auth.ts Normal file
View File

@@ -0,0 +1,331 @@
/**
* Authentication API Module
* Handles login, logout, token refresh, and user session management
*/
import { apiClient } from './client';
import { ApiError } from './types';
// Storage keys for authentication tokens
const ACCESS_TOKEN_KEY = 'grh_access_token';
const REFRESH_TOKEN_KEY = 'grh_refresh_token';
/**
* Login credentials interface
*/
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Authentication tokens interface
*/
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
/**
* Authenticated user interface
*/
export interface AuthUser {
id: string;
email: string;
name: string;
role: string;
avatar_url?: string;
}
/**
* Login response combining tokens and user data
*/
export interface LoginResponse extends AuthTokens {
user: AuthUser;
}
/**
* Refresh token response
*/
export interface RefreshResponse {
accessToken: string;
}
/**
* Store authentication tokens in localStorage
* @param tokens - The tokens to store
*/
function storeTokens(tokens: AuthTokens): void {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
} catch (error) {
console.error('Failed to store authentication tokens:', error);
}
}
/**
* Authenticate user with email and password
* @param credentials - The login credentials
* @returns Promise resolving to tokens and user data
*/
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
// Validate credentials
if (!credentials.email || !credentials.password) {
throw new ApiError('Email and password are required', 400);
}
const response = await apiClient.post<LoginResponse>(
'/api/auth/login',
credentials,
{ skipAuth: true }
);
// Store tokens on successful login
if (response.accessToken && response.refreshToken) {
storeTokens({
accessToken: response.accessToken,
refreshToken: response.refreshToken,
});
}
return response;
}
/**
* Refresh the access token using the stored refresh token
* @returns Promise resolving to the new access token
*/
export async function refresh(): Promise<RefreshResponse> {
const refreshToken = getStoredTokens()?.refreshToken;
if (!refreshToken) {
throw new ApiError('No refresh token available', 401);
}
const response = await apiClient.post<RefreshResponse>(
'/api/auth/refresh',
{ refreshToken },
{ skipAuth: true }
);
// Update stored access token
if (response.accessToken) {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken);
} catch (error) {
console.error('Failed to update access token:', error);
}
}
return response;
}
/**
* Log out the current user
* Clears tokens and optionally notifies the server
* @returns Promise resolving when logout is complete
*/
export async function logout(): Promise<void> {
try {
const refreshToken = getRefreshToken();
// Attempt to notify server about logout
// This allows the server to invalidate the refresh token
await apiClient.post('/api/auth/logout', { refreshToken }, {
skipAuth: false, // Include token so server knows which session to invalidate
});
} catch (error) {
// Continue with local logout even if server request fails
console.warn('Server logout request failed:', error);
} finally {
// Always clear local tokens
clearAuth();
}
}
/**
* Get the currently authenticated user's profile
* @returns Promise resolving to the user data
*/
export async function getMe(): Promise<AuthUser> {
return apiClient.get<AuthUser>('/api/auth/me');
}
/**
* Check if the user is currently authenticated
* Validates that an access token exists and is not obviously expired
* @returns boolean indicating authentication status
*/
export function isAuthenticated(): boolean {
const tokens = getStoredTokens();
if (!tokens?.accessToken) {
return false;
}
// Check if the token is a valid JWT and not expired
try {
const payload = parseJwtPayload(tokens.accessToken);
if (!payload) {
return false;
}
// Check if token has expired
if (payload.exp) {
const expirationTime = (payload.exp as number) * 1000; // Convert to milliseconds
const currentTime = Date.now();
// Consider token expired if less than 30 seconds remaining
if (currentTime >= expirationTime - 30000) {
return false;
}
}
return true;
} catch {
// If we can't parse the token, assume it's invalid
return false;
}
}
/**
* Get stored authentication tokens
* @returns The stored tokens or null if not found
*/
export function getStoredTokens(): AuthTokens | null {
try {
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!accessToken || !refreshToken) {
return null;
}
return {
accessToken,
refreshToken,
};
} catch {
return null;
}
}
/**
* Clear all authentication data from storage
*/
export function clearAuth(): void {
try {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch (error) {
console.error('Failed to clear authentication data:', error);
}
}
/**
* Parse JWT payload without verification
* Used for client-side token inspection (expiration check, etc.)
* @param token - The JWT token to parse
* @returns The parsed payload or null if invalid
*/
function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
// Handle base64url encoding
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
// Pad with '=' if necessary
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const decoded = atob(padded);
return JSON.parse(decoded);
} catch {
return null;
}
}
/**
* Get the access token for external use
* Useful when other parts of the app need the raw token
* @returns The access token or null
*/
export function getAccessToken(): string | null {
try {
return localStorage.getItem(ACCESS_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Get the refresh token for external use
* @returns The refresh token or null
*/
export function getRefreshToken(): string | null {
try {
return localStorage.getItem(REFRESH_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Check if the access token is about to expire (within threshold)
* @param thresholdMs - Time threshold in milliseconds (default: 5 minutes)
* @returns boolean indicating if token is expiring soon
*/
export function isTokenExpiringSoon(thresholdMs: number = 5 * 60 * 1000): boolean {
const tokens = getStoredTokens();
if (!tokens?.accessToken) {
return true;
}
try {
const payload = parseJwtPayload(tokens.accessToken);
if (!payload?.exp) {
return false; // Can't determine expiration, assume it's fine
}
const expirationTime = (payload.exp as number) * 1000;
const currentTime = Date.now();
return currentTime >= expirationTime - thresholdMs;
} catch {
return true;
}
}
/**
* Update user profile
* @param updates - The profile updates
* @returns Promise resolving to the updated user data
*/
export async function updateProfile(updates: Partial<Pick<AuthUser, 'name' | 'email'>>): Promise<AuthUser> {
return apiClient.patch<AuthUser>('/api/auth/me', updates);
}
/**
* Change user password
* @param currentPassword - The current password
* @param newPassword - The new password
* @returns Promise resolving when password is changed
*/
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await apiClient.post('/api/auth/change-password', {
currentPassword,
newPassword,
});
}

393
src/api/client.ts Normal file
View File

@@ -0,0 +1,393 @@
/**
* API Client with JWT Authentication
* Handles all HTTP requests with automatic token management
*/
import { ApiError } from './types';
// Storage keys for authentication tokens
const ACCESS_TOKEN_KEY = 'grh_access_token';
const REFRESH_TOKEN_KEY = 'grh_refresh_token';
// Base URL from environment variable
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
/**
* Request configuration options
*/
interface RequestOptions {
headers?: Record<string, string>;
params?: Record<string, string | number | boolean | undefined | null>;
skipAuth?: boolean;
}
/**
* Internal request configuration
*/
interface InternalRequestConfig {
method: string;
url: string;
data?: unknown;
options?: RequestOptions;
}
/**
* Flag to prevent multiple simultaneous refresh attempts
*/
let isRefreshing = false;
/**
* Queue of requests waiting for token refresh
*/
let refreshQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
/**
* Get stored access token from localStorage
*/
function getAccessToken(): string | null {
try {
return localStorage.getItem(ACCESS_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Get stored refresh token from localStorage
*/
function getRefreshToken(): string | null {
try {
return localStorage.getItem(REFRESH_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Store access token in localStorage
*/
function setAccessToken(token: string): void {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
} catch {
console.error('Failed to store access token');
}
}
/**
* Clear all auth tokens from localStorage
*/
function clearTokens(): void {
try {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch {
console.error('Failed to clear tokens');
}
}
/**
* Redirect to login page
*/
function redirectToLogin(): void {
clearTokens();
// Use window.location for a hard redirect to clear any state
window.location.href = '/login';
}
/**
* Build URL with query parameters
*/
function buildUrl(endpoint: string, params?: RequestOptions['params']): string {
const url = new URL(endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Build headers with optional authentication
*/
function buildHeaders(options?: RequestOptions): Headers {
const headers = new Headers({
'Content-Type': 'application/json',
...options?.headers,
});
if (!options?.skipAuth) {
const token = getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
return headers;
}
/**
* Attempt to refresh the access token
*/
async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new ApiError('No refresh token available', 401);
}
const response = await fetch(`${BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.error?.message || 'Token refresh failed',
response.status,
errorData.error?.errors
);
}
const data = await response.json();
const newAccessToken = data.accessToken || data.data?.accessToken;
if (!newAccessToken) {
throw new ApiError('Invalid refresh response', 401);
}
setAccessToken(newAccessToken);
return newAccessToken;
}
/**
* Handle token refresh with queue management
* Ensures only one refresh request is made at a time
*/
async function handleTokenRefresh(): Promise<string> {
if (isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise<string>((resolve, reject) => {
refreshQueue.push({ resolve, reject });
});
}
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
// Resolve all queued requests with the new token
refreshQueue.forEach(({ resolve }) => resolve(newToken));
refreshQueue = [];
return newToken;
} catch (error) {
// Reject all queued requests
refreshQueue.forEach(({ reject }) => reject(error as Error));
refreshQueue = [];
throw error;
} finally {
isRefreshing = false;
}
}
/**
* Parse response and handle errors
*/
async function parseResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type');
// Handle empty responses
if (response.status === 204 || !contentType) {
return undefined as T;
}
// Parse JSON response
if (contentType.includes('application/json')) {
const data = await response.json();
// Handle wrapped API responses
if (data && typeof data === 'object') {
if ('success' in data) {
if (data.success === false) {
throw new ApiError(
data.error?.message || 'Request failed',
response.status,
data.error?.errors
);
}
// If response has pagination, return object with data and pagination
if ('pagination' in data) {
return {
data: data.data,
pagination: data.pagination,
} as T;
}
return data.data as T;
}
}
return data as T;
}
// Handle text responses
const text = await response.text();
return text as T;
}
/**
* Core request function with retry logic for 401 errors
*/
async function request<T>(config: InternalRequestConfig): Promise<T> {
const { method, url, data, options } = config;
const makeRequest = async (authToken?: string): Promise<Response> => {
const headers = buildHeaders(options);
// Override with new token if provided (after refresh)
if (authToken) {
headers.set('Authorization', `Bearer ${authToken}`);
}
const fetchOptions: RequestInit = {
method,
headers,
};
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
fetchOptions.body = JSON.stringify(data);
}
return fetch(buildUrl(url, options?.params), fetchOptions);
};
try {
let response = await makeRequest();
// Handle 401 Unauthorized - attempt token refresh
if (response.status === 401 && !options?.skipAuth) {
try {
const newToken = await handleTokenRefresh();
// Retry the original request with new token
response = await makeRequest(newToken);
} catch (refreshError) {
// Refresh failed - redirect to login
redirectToLogin();
throw new ApiError('Session expired. Please log in again.', 401);
}
}
// Handle other error responses
if (!response.ok) {
let errorMessage = `Request failed with status ${response.status}`;
let errors: string[] | undefined;
try {
const errorData = await response.json();
if (errorData.error?.message) {
errorMessage = errorData.error.message;
errors = errorData.error.errors;
} else if (errorData.message) {
errorMessage = errorData.message;
}
} catch {
// Unable to parse error response, use default message
}
throw new ApiError(errorMessage, response.status, errors);
}
return parseResponse<T>(response);
} catch (error) {
// Re-throw ApiError instances
if (error instanceof ApiError) {
throw error;
}
// Handle network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new ApiError('Network error. Please check your connection.', 0);
}
// Handle other errors
throw new ApiError(
error instanceof Error ? error.message : 'An unexpected error occurred',
0
);
}
}
/**
* API Client object with HTTP methods
*/
export const apiClient = {
/**
* Perform a GET request
* @param url - The endpoint URL
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
get<T>(url: string, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'GET', url, options });
},
/**
* Perform a POST request
* @param url - The endpoint URL
* @param data - The request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
post<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'POST', url, data, options });
},
/**
* Perform a PUT request
* @param url - The endpoint URL
* @param data - The request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
put<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'PUT', url, data, options });
},
/**
* Perform a PATCH request
* @param url - The endpoint URL
* @param data - The request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
patch<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'PATCH', url, data, options });
},
/**
* Perform a DELETE request
* @param url - The endpoint URL
* @param data - Optional request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
delete<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'DELETE', url, data, options });
},
};
export default apiClient;

View File

@@ -1,210 +1,146 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const CONCENTRATORS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/mheif1vdgnyt8x2/records`;
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
/**
* Concentrators API
* Handles all concentrator-related API operations using the backend API client
*/
const getAuthHeaders = () => ({
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
});
import { apiClient } from './client';
export interface ConcentratorRecord {
id: string;
fields: {
"Area Name": string;
"Device S/N": string;
"Device Name": string;
"Device Time": string;
"Device Status": string;
"Operator": string;
"Installed Time": string;
"Communication Time": string;
"Instruction Manual": string;
};
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export interface ConcentratorsResponse {
records: ConcentratorRecord[];
next?: string;
prev?: string;
nestedNext?: string;
nestedPrev?: string;
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Concentrator type enum
*/
export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES';
/**
* Concentrator entity from the backend
*/
export interface Concentrator {
id: string;
"Area Name": string;
"Device S/N": string;
"Device Name": string;
"Device Time": string;
"Device Status": string;
"Operator": string;
"Installed Time": string;
"Communication Time": string;
"Instruction Manual": string;
serialNumber: string;
name: string;
projectId: string;
location: string | null;
type: ConcentratorType;
status: string;
ipAddress: string | null;
firmwareVersion: string | null;
lastCommunication: string | null;
createdAt: string;
updatedAt: string;
}
export const fetchConcentrators = async (): Promise<Concentrator[]> => {
try {
const url = new URL(CONCENTRATORS_API_URL);
url.searchParams.set('viewId', 'vw93mj98ylyxratm');
/**
* Input data for creating or updating a concentrator
*/
export interface ConcentratorInput {
serialNumber: string;
name: string;
projectId: string;
location?: string;
type?: ConcentratorType;
status?: string;
ipAddress?: string;
firmwareVersion?: string;
}
/**
* Fetch all concentrators, optionally filtered by project
* @param projectId - Optional project ID to filter concentrators
* @returns Promise resolving to an array of concentrators
*/
export async function fetchConcentrators(projectId?: string): Promise<Concentrator[]> {
const params = projectId ? { project_id: projectId } : undefined;
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/concentrators', { params });
const response = await fetch(url.toString(), {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch concentrators");
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Concentrator>(response.data);
}
const data: ConcentratorsResponse = await response.json();
// Handle array response (fallback)
return transformArray<Concentrator>(response as Record<string, unknown>[]);
}
return data.records.map((r: ConcentratorRecord) => ({
id: r.id,
"Area Name": r.fields["Area Name"] || "",
"Device S/N": r.fields["Device S/N"] || "",
"Device Name": r.fields["Device Name"] || "",
"Device Time": r.fields["Device Time"] || "",
"Device Status": r.fields["Device Status"] || "",
"Operator": r.fields["Operator"] || "",
"Installed Time": r.fields["Installed Time"] || "",
"Communication Time": r.fields["Communication Time"] || "",
"Instruction Manual": r.fields["Instruction Manual"] || "",
}));
} catch (error) {
console.error("Error fetching concentrators:", error);
throw error;
}
};
/**
* Fetch a single concentrator by ID
* @param id - The concentrator ID
* @returns Promise resolving to the concentrator
*/
export async function fetchConcentrator(id: string): Promise<Concentrator> {
const response = await apiClient.get<Record<string, unknown>>(`/api/concentrators/${id}`);
return transformKeys<Concentrator>(response);
}
export const createConcentrator = async (
concentratorData: Omit<Concentrator, "id">
): Promise<Concentrator> => {
try {
const response = await fetch(CONCENTRATORS_API_URL, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
fields: {
"Area Name": concentratorData["Area Name"],
"Device S/N": concentratorData["Device S/N"],
"Device Name": concentratorData["Device Name"],
"Device Time": concentratorData["Device Time"],
"Device Status": concentratorData["Device Status"],
"Operator": concentratorData["Operator"],
"Installed Time": concentratorData["Installed Time"],
"Communication Time": concentratorData["Communication Time"],
"Instruction Manual": concentratorData["Instruction Manual"],
},
}),
});
if (!response.ok) {
throw new Error(`Failed to create concentrator: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const createdRecord = data.records?.[0];
if (!createdRecord) {
throw new Error("Invalid response format: no record returned");
}
return {
id: createdRecord.id,
"Area Name": createdRecord.fields["Area Name"] || concentratorData["Area Name"],
"Device S/N": createdRecord.fields["Device S/N"] || concentratorData["Device S/N"],
"Device Name": createdRecord.fields["Device Name"] || concentratorData["Device Name"],
"Device Time": createdRecord.fields["Device Time"] || concentratorData["Device Time"],
"Device Status": createdRecord.fields["Device Status"] || concentratorData["Device Status"],
"Operator": createdRecord.fields["Operator"] || concentratorData["Operator"],
"Installed Time": createdRecord.fields["Installed Time"] || concentratorData["Installed Time"],
"Communication Time": createdRecord.fields["Communication Time"] || concentratorData["Communication Time"],
"Instruction Manual": createdRecord.fields["Instruction Manual"] || concentratorData["Instruction Manual"],
/**
* Create a new concentrator
* @param data - The concentrator data
* @returns Promise resolving to the created concentrator
*/
export async function createConcentrator(data: ConcentratorInput): Promise<Concentrator> {
const backendData = {
serial_number: data.serialNumber,
name: data.name,
project_id: data.projectId,
location: data.location,
type: data.type,
status: data.status,
ip_address: data.ipAddress,
firmware_version: data.firmwareVersion,
};
} catch (error) {
console.error("Error creating concentrator:", error);
throw error;
}
};
const response = await apiClient.post<Record<string, unknown>>('/api/concentrators', backendData);
return transformKeys<Concentrator>(response);
}
export const updateConcentrator = async (
id: string,
concentratorData: Omit<Concentrator, "id">
): Promise<Concentrator> => {
try {
const response = await fetch(CONCENTRATORS_API_URL, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
fields: {
"Area Name": concentratorData["Area Name"],
"Device S/N": concentratorData["Device S/N"],
"Device Name": concentratorData["Device Name"],
"Device Time": concentratorData["Device Time"],
"Device Status": concentratorData["Device Status"],
"Operator": concentratorData["Operator"],
"Installed Time": concentratorData["Installed Time"],
"Communication Time": concentratorData["Communication Time"],
"Instruction Manual": concentratorData["Instruction Manual"],
},
}),
});
/**
* Update an existing concentrator
* @param id - The concentrator ID
* @param data - The updated concentrator data
* @returns Promise resolving to the updated concentrator
*/
export async function updateConcentrator(id: string, data: Partial<ConcentratorInput>): Promise<Concentrator> {
const backendData: Record<string, unknown> = {};
if (data.serialNumber !== undefined) backendData.serial_number = data.serialNumber;
if (data.name !== undefined) backendData.name = data.name;
if (data.projectId !== undefined) backendData.project_id = data.projectId;
if (data.location !== undefined) backendData.location = data.location;
if (data.type !== undefined) backendData.type = data.type;
if (data.status !== undefined) backendData.status = data.status;
if (data.ipAddress !== undefined) backendData.ip_address = data.ipAddress;
if (data.firmwareVersion !== undefined) backendData.firmware_version = data.firmwareVersion;
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(`Bad Request: ${errorData.msg || "Invalid data provided"}`);
}
throw new Error(`Failed to update concentrator: ${response.status} ${response.statusText}`);
}
const response = await apiClient.patch<Record<string, unknown>>(`/api/concentrators/${id}`, backendData);
return transformKeys<Concentrator>(response);
}
const data = await response.json();
const updatedRecord = data.records?.[0];
if (!updatedRecord) {
throw new Error("Invalid response format: no record returned");
}
return {
id: updatedRecord.id,
"Area Name": updatedRecord.fields["Area Name"] || concentratorData["Area Name"],
"Device S/N": updatedRecord.fields["Device S/N"] || concentratorData["Device S/N"],
"Device Name": updatedRecord.fields["Device Name"] || concentratorData["Device Name"],
"Device Time": updatedRecord.fields["Device Time"] || concentratorData["Device Time"],
"Device Status": updatedRecord.fields["Device Status"] || concentratorData["Device Status"],
"Operator": updatedRecord.fields["Operator"] || concentratorData["Operator"],
"Installed Time": updatedRecord.fields["Installed Time"] || concentratorData["Installed Time"],
"Communication Time": updatedRecord.fields["Communication Time"] || concentratorData["Communication Time"],
"Instruction Manual": updatedRecord.fields["Instruction Manual"] || concentratorData["Instruction Manual"],
};
} catch (error) {
console.error("Error updating concentrator:", error);
throw error;
}
};
export const deleteConcentrator = async (id: string): Promise<void> => {
try {
const response = await fetch(CONCENTRATORS_API_URL, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
}),
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(`Bad Request: ${errorData.msg || "Invalid data provided"}`);
}
throw new Error(`Failed to delete concentrator: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error("Error deleting concentrator:", error);
throw error;
}
};
/**
* Delete a concentrator
* @param id - The concentrator ID
* @returns Promise resolving when the concentrator is deleted
*/
export async function deleteConcentrator(id: string): Promise<void> {
return apiClient.delete<void>(`/api/concentrators/${id}`);
}

View File

@@ -1,45 +1,83 @@
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
const form = new FormData();
form.append("avatar", file);
/**
* User Profile API
* Handles all user profile-related API operations using the backend API client
*/
const res = await fetch("/api/me/avatar", {
method: "POST",
body: form,
// NO pongas Content-Type; el browser lo agrega con boundary
headers: {
// Si usas token:
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
},
});
import { apiClient } from './client';
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Upload avatar failed: ${res.status} ${text}`);
}
const data = await res.json();
if (!data?.avatarUrl) throw new Error("Respuesta sin avatarUrl");
return { avatarUrl: data.avatarUrl };
}
export async function updateMyProfile(input: {
name: string;
/**
* User entity from the backend
*/
export interface User {
id: string;
email: string;
}): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> {
const res = await fetch("/api/me", {
method: "PUT",
name: string;
avatarUrl: string | null;
role: string;
createdAt: string;
updatedAt: string;
}
/**
* Get the current user's profile
* @returns Promise resolving to the user profile
*/
export async function getMyProfile(): Promise<User> {
return apiClient.get<User>('/api/me');
}
/**
* Update the current user's profile
* @param data - The updated profile data
* @returns Promise resolving to the updated user profile
*/
export async function updateMyProfile(data: { name?: string; email?: string }): Promise<User> {
return apiClient.put<User>('/api/me', data);
}
/**
* Upload a new avatar for the current user
* @param file - The avatar image file
* @returns Promise resolving to the new avatar URL
*/
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
// For file uploads, we need to use FormData and handle it differently
const formData = new FormData();
formData.append('avatar', file);
const token = localStorage.getItem('grh_access_token');
const response = await fetch('/api/me/avatar', {
method: 'POST',
headers: {
"Content-Type": "application/json",
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(input),
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Update profile failed: ${res.status} ${text}`);
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(`Upload avatar failed: ${response.status} ${errorText}`);
}
return res.json();
const data = await response.json();
if (!data?.avatarUrl && !data?.data?.avatarUrl) {
throw new Error('Response missing avatarUrl');
}
return { avatarUrl: data.avatarUrl || data.data?.avatarUrl };
}
/**
* Change the current user's password
* @param currentPassword - The current password
* @param newPassword - The new password
* @returns Promise resolving when the password is changed
*/
export async function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
return apiClient.post<void>('/api/me/password', {
currentPassword,
newPassword,
});
}

View File

@@ -1,312 +1,265 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const METERS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/m4hzpnopjkppaav/records`;
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
/**
* Meters API
* Handles all meter-related API operations using the backend API client
*/
const getAuthHeaders = () => ({
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
});
import { apiClient } from './client';
export interface MeterRecord {
id: string;
fields: {
CreatedAt: string;
UpdatedAt: string;
"Area Name": string;
"Account Number": string | null;
"User Name": string | null;
"User Address": string | null;
"Meter S/N": string;
"Meter Name": string;
"Meter Status": string;
"Protocol Type": string;
"Price No.": string | null;
"Price Name": string | null;
"DMA Partition": string | null;
"Supply Types": string;
"Device ID": string;
"Device Name": string;
"Device Type": string;
"Usage Analysis Type": string;
"installed Time": string;
};
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export interface MetersResponse {
records: MeterRecord[];
next?: string;
prev?: string;
nestedNext?: string;
nestedPrev?: string;
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Meter entity from the backend
* Meters belong to Concentrators (LORA protocol)
*/
export interface Meter {
id: string;
serialNumber: string;
meterId: string | null;
name: string;
concentratorId: string;
location: string | null;
type: string;
status: string;
lastReadingValue: number | null;
lastReadingAt: string | null;
installationDate: string | null;
createdAt: string;
updatedAt: string;
areaName: string;
accountNumber: string | null;
userName: string | null;
userAddress: string | null;
meterSerialNumber: string;
meterName: string;
meterStatus: string;
protocolType: string;
priceNo: string | null;
priceName: string | null;
dmaPartition: string | null;
supplyTypes: string;
deviceId: string;
deviceName: string;
deviceType: string;
usageAnalysisType: string;
installedTime: string;
// From joins
concentratorName?: string;
concentratorSerial?: string;
projectId?: string;
projectName?: string;
}
export const fetchMeters = async (): Promise<Meter[]> => {
const pageSize = 9999;
try {
const url = new URL(METERS_API_URL);
url.searchParams.set('viewId', 'vwo7tqwu8fi6ie83');
url.searchParams.set('pageSize', pageSize.toString());
/**
* Input data for creating or updating a meter
*/
export interface MeterInput {
serialNumber: string;
meterId?: string;
name: string;
concentratorId: string;
location?: string;
type?: string;
status?: string;
installationDate?: string;
}
const response = await fetch(url.toString(), {
method: "GET",
headers: getAuthHeaders()
});
/**
* Meter reading entity
*/
export interface MeterReading {
id: string;
meterId: string;
value: number;
unit: string;
readingType: string;
readAt: string;
createdAt: string;
}
if (!response.ok) {
throw new Error("Failed to fetch meters");
}
const data: MetersResponse = await response.json();
const ans = data.records.map((r: MeterRecord) => ({
id: r.id,
createdAt: r.fields.CreatedAt || "",
updatedAt: r.fields.UpdatedAt || "",
areaName: r.fields["Area Name"] || "",
accountNumber: r.fields["Account Number"] || null,
userName: r.fields["User Name"] || null,
userAddress: r.fields["User Address"] || null,
meterSerialNumber: r.fields["Meter S/N"] || "",
meterName: r.fields["Meter Name"] || "",
meterStatus: r.fields["Meter Status"] || "",
protocolType: r.fields["Protocol Type"] || "",
priceNo: r.fields["Price No."] || null,
priceName: r.fields["Price Name"] || null,
dmaPartition: r.fields["DMA Partition"] || null,
supplyTypes: r.fields["Supply Types"] || "",
deviceId: r.fields["Device ID"] || "",
deviceName: r.fields["Device Name"] || "",
deviceType: r.fields["Device Type"] || "",
usageAnalysisType: r.fields["Usage Analysis Type"] || "",
installedTime: r.fields["installed Time"] || "",
}));
return ans;
} catch (error) {
console.error("Error fetching meters:", error);
throw error;
}
};
export const createMeter = async (
meterData: Omit<Meter, "id">
): Promise<Meter> => {
try {
const response = await fetch(METERS_API_URL, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
fields: {
CreatedAt: meterData.createdAt,
UpdatedAt: meterData.updatedAt,
"Area Name": meterData.areaName,
"Account Number": meterData.accountNumber,
"User Name": meterData.userName,
"User Address": meterData.userAddress,
"Meter S/N": meterData.meterSerialNumber,
"Meter Name": meterData.meterName,
"Meter Status": meterData.meterStatus,
"Protocol Type": meterData.protocolType,
"Price No.": meterData.priceNo,
"Price Name": meterData.priceName,
"DMA Partition": meterData.dmaPartition,
"Supply Types": meterData.supplyTypes,
"Device ID": meterData.deviceId,
"Device Name": meterData.deviceName,
"Device Type": meterData.deviceType,
"Usage Analysis Type": meterData.usageAnalysisType,
"Installed Time": meterData.installedTime,
},
}),
});
if (!response.ok) {
throw new Error(
`Failed to create meter: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
const createdRecord = data.records?.[0];
if (!createdRecord) {
throw new Error("Invalid response format: no record returned");
}
return {
id: createdRecord.id,
createdAt: createdRecord.fields.CreatedAt || meterData.createdAt,
updatedAt: createdRecord.fields.UpdatedAt || meterData.updatedAt,
areaName: createdRecord.fields["Area Name"] || meterData.areaName,
accountNumber:
createdRecord.fields["Account Number"] || meterData.accountNumber,
userName: createdRecord.fields["User Name"] || meterData.userName,
userAddress:
createdRecord.fields["User Address"] || meterData.userAddress,
meterSerialNumber:
createdRecord.fields["Meter S/N"] || meterData.meterSerialNumber,
meterName: createdRecord.fields["Meter Name"] || meterData.meterName,
meterStatus:
createdRecord.fields["Meter Status"] || meterData.meterStatus,
protocolType:
createdRecord.fields["Protocol Type"] || meterData.protocolType,
priceNo: createdRecord.fields["Price No."] || meterData.priceNo,
priceName: createdRecord.fields["Price Name"] || meterData.priceName,
dmaPartition:
createdRecord.fields["DMA Partition"] || meterData.dmaPartition,
supplyTypes:
createdRecord.fields["Supply Types"] || meterData.supplyTypes,
deviceId: createdRecord.fields["Device ID"] || meterData.deviceId,
deviceName: createdRecord.fields["Device Name"] || meterData.deviceName,
deviceType: createdRecord.fields["Device Type"] || meterData.deviceType,
usageAnalysisType:
createdRecord.fields["Usage Analysis Type"] ||
meterData.usageAnalysisType,
installedTime:
createdRecord.fields["Installed Time"] || meterData.installedTime,
/**
* Fetch all meters, optionally filtered by concentrator or project
* @param filters - Optional filters (concentratorId, projectId)
* @returns Promise resolving to an array of meters
*/
export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise<Meter[]> {
const params: Record<string, string> = {
pageSize: '1000', // Request up to 1000 meters
};
} catch (error) {
console.error("Error creating meter:", error);
throw error;
}
};
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
if (filters?.projectId) params.project_id = filters.projectId;
export const updateMeter = async (
id: string,
meterData: Omit<Meter, "id">
): Promise<Meter> => {
try {
const response = await fetch(METERS_API_URL, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
fields: {
CreatedAt: meterData.createdAt,
UpdatedAt: meterData.updatedAt,
"Area Name": meterData.areaName,
"Account Number": meterData.accountNumber,
"User Name": meterData.userName,
"User Address": meterData.userAddress,
"Meter S/N": meterData.meterSerialNumber,
"Meter Name": meterData.meterName,
"Meter Status": meterData.meterStatus,
"Protocol Type": meterData.protocolType,
"Price No.": meterData.priceNo,
"Price Name": meterData.priceName,
"DMA Partition": meterData.dmaPartition,
"Supply Types": meterData.supplyTypes,
"Device ID": meterData.deviceId,
"Device Name": meterData.deviceName,
"Device Type": meterData.deviceType,
"Usage Analysis Type": meterData.usageAnalysisType,
"Installed Time": meterData.installedTime,
},
}),
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: unknown }>('/api/meters', {
params
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
}
throw new Error(
`Failed to update meter: ${response.status} ${response.statusText}`
);
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response) {
return transformArray<Meter>(response.data);
}
const data = await response.json();
const updatedRecord = data.records?.[0];
// Fallback for non-paginated response
return transformArray<Meter>(response as unknown as Record<string, unknown>[]);
}
if (!updatedRecord) {
throw new Error("Invalid response format: no record returned");
}
/**
* Fetch a single meter by ID
* @param id - The meter ID
* @returns Promise resolving to the meter
*/
export async function fetchMeter(id: string): Promise<Meter> {
const response = await apiClient.get<Record<string, unknown>>(`/api/meters/${id}`);
return transformKeys<Meter>(response);
}
return {
id: updatedRecord.id,
createdAt: updatedRecord.fields.CreatedAt || meterData.createdAt,
updatedAt: updatedRecord.fields.UpdatedAt || meterData.updatedAt,
areaName: updatedRecord.fields["Area Name"] || meterData.areaName,
accountNumber:
updatedRecord.fields["Account Number"] || meterData.accountNumber,
userName: updatedRecord.fields["User Name"] || meterData.userName,
userAddress:
updatedRecord.fields["User Address"] || meterData.userAddress,
meterSerialNumber:
updatedRecord.fields["Meter S/N"] || meterData.meterSerialNumber,
meterName: updatedRecord.fields["Meter Name"] || meterData.meterName,
meterStatus:
updatedRecord.fields["Meter Status"] || meterData.meterStatus,
protocolType:
updatedRecord.fields["Protocol Type"] || meterData.protocolType,
priceNo: updatedRecord.fields["Price No."] || meterData.priceNo,
priceName: updatedRecord.fields["Price Name"] || meterData.priceName,
dmaPartition:
updatedRecord.fields["DMA Partition"] || meterData.dmaPartition,
supplyTypes:
updatedRecord.fields["Supply Types"] || meterData.supplyTypes,
deviceId: updatedRecord.fields["Device ID"] || meterData.deviceId,
deviceName: updatedRecord.fields["Device Name"] || meterData.deviceName,
deviceType: updatedRecord.fields["Device Type"] || meterData.deviceType,
usageAnalysisType:
updatedRecord.fields["Usage Analysis Type"] ||
meterData.usageAnalysisType,
installedTime:
updatedRecord.fields["Installed Time"] || meterData.installedTime,
/**
* Create a new meter
* @param data - The meter data
* @returns Promise resolving to the created meter
*/
export async function createMeter(data: MeterInput): Promise<Meter> {
// Convert camelCase to snake_case for backend
const backendData = {
serial_number: data.serialNumber,
meter_id: data.meterId,
name: data.name,
concentrator_id: data.concentratorId,
location: data.location,
type: data.type,
status: data.status,
installation_date: data.installationDate,
};
} catch (error) {
console.error("Error updating meter:", error);
throw error;
}
};
const response = await apiClient.post<Record<string, unknown>>('/api/meters', backendData);
return transformKeys<Meter>(response);
}
export const deleteMeter = async (id: string): Promise<void> => {
try {
const response = await fetch(METERS_API_URL, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
}),
/**
* Update an existing meter
* @param id - The meter ID
* @param data - The updated meter data
* @returns Promise resolving to the updated meter
*/
export async function updateMeter(id: string, data: Partial<MeterInput>): Promise<Meter> {
// Convert camelCase to snake_case for backend
const backendData: Record<string, unknown> = {};
if (data.serialNumber !== undefined) backendData.serial_number = data.serialNumber;
if (data.meterId !== undefined) backendData.meter_id = data.meterId;
if (data.name !== undefined) backendData.name = data.name;
if (data.concentratorId !== undefined) backendData.concentrator_id = data.concentratorId;
if (data.location !== undefined) backendData.location = data.location;
if (data.type !== undefined) backendData.type = data.type;
if (data.status !== undefined) backendData.status = data.status;
if (data.installationDate !== undefined) backendData.installation_date = data.installationDate;
const response = await apiClient.patch<Record<string, unknown>>(`/api/meters/${id}`, backendData);
return transformKeys<Meter>(response);
}
/**
* Delete a meter
* @param id - The meter ID
* @returns Promise resolving when the meter is deleted
*/
export async function deleteMeter(id: string): Promise<void> {
return apiClient.delete<void>(`/api/meters/${id}`);
}
/**
* Fetch readings for a specific meter
* @param id - The meter ID
* @returns Promise resolving to an array of meter readings
*/
export async function fetchMeterReadings(id: string): Promise<MeterReading[]> {
return apiClient.get<MeterReading[]>(`/api/meters/${id}/readings`);
}
/**
* Bulk upload result interface
*/
export interface BulkUploadResult {
success: boolean;
data: {
totalRows: number;
inserted: number;
failed: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
};
}
/**
* Bulk upload meters from Excel file
* @param file - Excel file to upload
* @returns Promise resolving to upload result
*/
export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
if (response.status === 400) {
const error = await response.json();
throw new Error(error.error || 'Error en la carga masiva');
}
return response.json();
}
/**
* Download meter template Excel file
*/
export async function downloadMeterTemplate(): Promise<void> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
// Try to get error message from response
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
throw new Error(errorData.error || `Error ${response.status}: ${response.statusText}`);
}
throw new Error(
`Failed to delete meter: ${response.status} ${response.statusText}`
);
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error("Error deleting meter:", error);
throw error;
}
};
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_medidores.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}

View File

@@ -1,247 +1,137 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const PROJECTS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/m9882vn3xb31e29/records`;
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
/**
* Projects API
* Handles all project-related API operations using the backend API client
*/
export const getAuthHeaders = () => ({
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN}`,
});
import { apiClient } from './client';
export interface ProjectRecord {
id: number;
fields: {
"Area Name"?: string;
"Device S/N"?: string;
"Device Name"?: string;
"Device Type"?: string;
"Device Status"?: string;
Operator?: string;
"Installed Time"?: string;
"Communication time"?: string;
"Instruction Manual"?: string | null;
};
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export interface ProjectsResponse {
records: ProjectRecord[];
next?: string;
prev?: string;
nestedNext?: string;
nestedPrev?: string;
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Project entity from the backend
*/
export interface Project {
id: string;
name: string;
description: string | null;
areaName: string;
deviceSN: string;
deviceName: string;
deviceType: string;
deviceStatus: "ACTIVE" | "INACTIVE";
operator: string;
installedTime: string;
communicationTime: string;
location: string | null;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export const fetchProjectNames = async (): Promise<string[]> => {
try {
const response = await fetch(PROJECTS_API_URL, {
method: "GET",
headers: getAuthHeaders(),
});
/**
* Input data for creating or updating a project
*/
export interface ProjectInput {
name: string;
description?: string;
areaName: string;
location?: string;
status?: string;
}
if (!response.ok) {
throw new Error("Failed to fetch projects");
/**
* Fetch all projects
* @returns Promise resolving to an array of projects
*/
export async function fetchProjects(): Promise<Project[]> {
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/projects');
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Project>(response.data);
}
const data: ProjectsResponse = await response.json();
// Handle array response (fallback)
return transformArray<Project>(response as Record<string, unknown>[]);
}
if (!data.records || data.records.length === 0) {
console.warn("No project records found from API");
return [];
}
/**
* Fetch a single project by ID
* @param id - The project ID
* @returns Promise resolving to the project
*/
export async function fetchProject(id: string): Promise<Project> {
const response = await apiClient.get<Record<string, unknown>>(`/api/projects/${id}`);
return transformKeys<Project>(response);
}
const projectNames = [
...new Set(
data.records
.map((record) => record.fields["Area Name"] || "")
.filter((name) => name)
),
];
return projectNames;
} catch (error) {
console.error("Error fetching project names:", error);
return [];
}
};
export const fetchProjects = async (): Promise<Project[]> => {
try {
const url = new URL(PROJECTS_API_URL);
url.searchParams.set('viewId', 'vwrrxvlzlxi7jfe7');
const response = await fetch(url.toString(), {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch projects");
}
const data: ProjectsResponse = await response.json();
return data.records.map((r: ProjectRecord) => ({
id: r.id.toString(),
areaName: r.fields["Area Name"] ?? "",
deviceSN: r.fields["Device S/N"] ?? "",
deviceName: r.fields["Device Name"] ?? "",
deviceType: r.fields["Device Type"] ?? "",
deviceStatus:
r.fields["Device Status"] === "Installed" ? "ACTIVE" : "INACTIVE",
operator: r.fields["Operator"] ?? "",
installedTime: r.fields["Installed Time"] ?? "",
communicationTime: r.fields["Communication time"] ?? "",
instructionManual: "",
}));
} catch (error) {
console.error("Error fetching projects:", error);
throw error;
}
};
export const createProject = async (
projectData: Omit<Project, "id">
): Promise<Project> => {
const response = await fetch(PROJECTS_API_URL, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
fields: {
"Area Name": projectData.areaName,
"Device S/N": projectData.deviceSN,
"Device Name": projectData.deviceName,
"Device Type": projectData.deviceType,
"Device Status":
projectData.deviceStatus === "ACTIVE" ? "Installed" : "Inactive",
Operator: projectData.operator,
"Installed Time": projectData.installedTime,
"Communication time": projectData.communicationTime,
},
}),
});
if (!response.ok) {
throw new Error(
`Failed to create project: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
const createdRecord = data.records?.[0];
if (!createdRecord) {
throw new Error("Invalid response format: no record returned");
}
return {
id: createdRecord.id.toString(),
areaName: createdRecord.fields["Area Name"] ?? projectData.areaName,
deviceSN: createdRecord.fields["Device S/N"] ?? projectData.deviceSN,
deviceName: createdRecord.fields["Device Name"] ?? projectData.deviceName,
deviceType: createdRecord.fields["Device Type"] ?? projectData.deviceType,
deviceStatus:
createdRecord.fields["Device Status"] === "Installed"
? "ACTIVE"
: "INACTIVE",
operator: createdRecord.fields["Operator"] ?? projectData.operator,
installedTime:
createdRecord.fields["Installed Time"] ?? projectData.installedTime,
communicationTime:
createdRecord.fields["Communication time"] ??
projectData.communicationTime,
/**
* Create a new project
* @param data - The project data
* @returns Promise resolving to the created project
*/
export async function createProject(data: ProjectInput): Promise<Project> {
const backendData = {
name: data.name,
description: data.description,
area_name: data.areaName,
location: data.location,
status: data.status,
};
};
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
return transformKeys<Project>(response);
}
export const updateProject = async (
id: string,
projectData: Omit<Project, "id">
): Promise<Project> => {
const response = await fetch(PROJECTS_API_URL, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({
id: parseInt(id),
fields: {
"Area Name": projectData.areaName,
"Device S/N": projectData.deviceSN,
"Device Name": projectData.deviceName,
"Device Type": projectData.deviceType,
"Device Status":
projectData.deviceStatus === "ACTIVE" ? "Installed" : "Inactive",
Operator: projectData.operator,
"Installed Time": projectData.installedTime,
"Communication time": projectData.communicationTime,
},
}),
});
/**
* Update an existing project
* @param id - The project ID
* @param data - The updated project data
* @returns Promise resolving to the updated project
*/
export async function updateProject(id: string, data: Partial<ProjectInput>): Promise<Project> {
const backendData: Record<string, unknown> = {};
if (data.name !== undefined) backendData.name = data.name;
if (data.description !== undefined) backendData.description = data.description;
if (data.areaName !== undefined) backendData.area_name = data.areaName;
if (data.location !== undefined) backendData.location = data.location;
if (data.status !== undefined) backendData.status = data.status;
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
}
throw new Error(
`Failed to update project: ${response.status} ${response.statusText}`
);
}
const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData);
return transformKeys<Project>(response);
}
const data = await response.json();
/**
* Delete a project
* @param id - The project ID
* @returns Promise resolving when the project is deleted
*/
export async function deleteProject(id: string): Promise<void> {
return apiClient.delete<void>(`/api/projects/${id}`);
}
const updatedRecord = data.records?.[0];
if (!updatedRecord) {
throw new Error("Invalid response format: no record returned");
}
return {
id: updatedRecord.id.toString(),
areaName: updatedRecord.fields["Area Name"] ?? projectData.areaName,
deviceSN: updatedRecord.fields["Device S/N"] ?? projectData.deviceSN,
deviceName: updatedRecord.fields["Device Name"] ?? projectData.deviceName,
deviceType: updatedRecord.fields["Device Type"] ?? projectData.deviceType,
deviceStatus:
updatedRecord.fields["Device Status"] === "Installed"
? "ACTIVE"
: "INACTIVE",
operator: updatedRecord.fields["Operator"] ?? projectData.operator,
installedTime:
updatedRecord.fields["Installed Time"] ?? projectData.installedTime,
communicationTime:
updatedRecord.fields["Communication time"] ??
projectData.communicationTime,
};
};
export const deleteProject = async (id: string): Promise<void> => {
const response = await fetch(PROJECTS_API_URL, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
}),
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
}
throw new Error(
`Failed to delete project: ${response.status} ${response.statusText}`
);
}
};
/**
* Fetch unique area names from all projects
* @returns Promise resolving to an array of unique area names
*/
export async function fetchProjectNames(): Promise<string[]> {
const projects = await fetchProjects();
const areaNames = [...new Set(projects.map(p => p.areaName).filter(Boolean))];
return areaNames;
}

271
src/api/readings.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* Readings API
* Handles all meter reading-related API operations using the backend API client
*/
import { apiClient } from './client';
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Meter reading entity from the backend
*/
export interface MeterReading {
id: string;
meterId: string;
readingValue: number;
readingType: string;
batteryLevel: number | null;
signalStrength: number | null;
rawPayload: string | null;
receivedAt: string;
createdAt: string;
// From join with meters
meterSerialNumber: string;
meterName: string;
meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectId: string;
projectName: string;
}
/**
* Consumption summary statistics
*/
export interface ConsumptionSummary {
totalReadings: number;
totalMeters: number;
avgReading: number;
lastReadingDate: string | null;
}
/**
* Pagination info from API response
*/
export interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
/**
* Paginated response
*/
export interface PaginatedResponse<T> {
data: T[];
pagination: Pagination;
}
/**
* Filters for fetching readings
*/
export interface ReadingFilters {
meterId?: string;
projectId?: string;
concentratorId?: string;
startDate?: string;
endDate?: string;
readingType?: string;
page?: number;
pageSize?: number;
}
/**
* Fetch all readings with optional filtering and pagination
* @param filters - Optional filters for the query
* @returns Promise resolving to paginated readings
*/
export async function fetchReadings(filters?: ReadingFilters): Promise<PaginatedResponse<MeterReading>> {
const params: Record<string, string | number> = {};
if (filters?.meterId) params.meter_id = filters.meterId;
if (filters?.projectId) params.project_id = filters.projectId;
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
if (filters?.startDate) params.start_date = filters.startDate;
if (filters?.endDate) params.end_date = filters.endDate;
if (filters?.readingType) params.reading_type = filters.readingType;
if (filters?.page) params.page = filters.page;
if (filters?.pageSize) params.pageSize = filters.pageSize;
const response = await apiClient.get<{
data: Record<string, unknown>[];
pagination: Pagination;
}>('/api/readings', { params });
return {
data: transformArray<MeterReading>(response.data),
pagination: response.pagination,
};
}
/**
* Fetch a single reading by ID
* @param id - The reading ID
* @returns Promise resolving to the reading
*/
export async function fetchReading(id: string): Promise<MeterReading> {
const response = await apiClient.get<Record<string, unknown>>(`/api/readings/${id}`);
return transformKeys<MeterReading>(response);
}
/**
* Fetch consumption summary statistics
* @param projectId - Optional project ID to filter
* @returns Promise resolving to the summary
*/
export async function fetchConsumptionSummary(projectId?: string): Promise<ConsumptionSummary> {
const params = projectId ? { project_id: projectId } : undefined;
const response = await apiClient.get<Record<string, unknown>>('/api/readings/summary', { params });
return transformKeys<ConsumptionSummary>(response);
}
/**
* Input data for creating a reading
*/
export interface ReadingInput {
meterId: string;
readingValue: number;
readingType?: string;
batteryLevel?: number;
signalStrength?: number;
rawPayload?: string;
receivedAt?: string;
}
/**
* Create a new reading
* @param data - The reading data
* @returns Promise resolving to the created reading
*/
export async function createReading(data: ReadingInput): Promise<MeterReading> {
const backendData = {
meter_id: data.meterId,
reading_value: data.readingValue,
reading_type: data.readingType,
battery_level: data.batteryLevel,
signal_strength: data.signalStrength,
raw_payload: data.rawPayload,
received_at: data.receivedAt,
};
const response = await apiClient.post<Record<string, unknown>>('/api/readings', backendData);
return transformKeys<MeterReading>(response);
}
/**
* Delete a reading
* @param id - The reading ID
* @returns Promise resolving when the reading is deleted
*/
export async function deleteReading(id: string): Promise<void> {
return apiClient.delete<void>(`/api/readings/${id}`);
}
/**
* Bulk upload result interface
*/
export interface BulkUploadResult {
success: boolean;
data: {
totalRows: number;
inserted: number;
failed: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
};
}
/**
* Bulk upload readings from Excel file
* @param file - Excel file to upload
* @returns Promise resolving to upload result
*/
export async function bulkUploadReadings(file: File): Promise<BulkUploadResult> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/readings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Error en la carga masiva');
}
return response.json();
}
/**
* Download readings template Excel file
*/
export async function downloadReadingTemplate(): Promise<void> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/readings/template`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Error ${response.status}: ${response.statusText}`);
}
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_lecturas.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}

133
src/api/types.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* API Types and Error Classes
* Common types used across the API client
*/
/**
* Standard API response wrapper for successful responses
*/
export interface ApiSuccessResponse<T> {
success: true;
data: T;
}
/**
* Standard API response wrapper for error responses
*/
export interface ApiErrorResponse {
success: false;
error: {
message: string;
errors?: string[];
};
}
/**
* Union type for all API responses
*/
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
/**
* Pagination metadata
*/
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
/**
* Paginated response wrapper
*/
export interface PaginatedResponse<T> {
success: true;
data: T[];
pagination: PaginationMeta;
}
/**
* Custom API Error class with status code and validation errors
*/
export class ApiError extends Error {
public readonly status: number;
public readonly errors?: string[];
constructor(message: string, status: number, errors?: string[]) {
super(message);
this.name = 'ApiError';
this.status = status;
this.errors = errors;
// Ensure instanceof works correctly
Object.setPrototypeOf(this, ApiError.prototype);
}
/**
* Check if this error is an authentication error
*/
isAuthError(): boolean {
return this.status === 401;
}
/**
* Check if this error is a forbidden error
*/
isForbiddenError(): boolean {
return this.status === 403;
}
/**
* Check if this error is a not found error
*/
isNotFoundError(): boolean {
return this.status === 404;
}
/**
* Check if this error is a validation error
*/
isValidationError(): boolean {
return this.status === 400 || this.status === 422;
}
/**
* Check if this error is a server error
*/
isServerError(): boolean {
return this.status >= 500;
}
/**
* Convert error to a plain object for logging or serialization
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
status: this.status,
errors: this.errors,
};
}
}
/**
* Type guard to check if a response is successful
*/
export function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccessResponse<T> {
return response.success === true;
}
/**
* Type guard to check if a response is an error
*/
export function isApiError<T>(response: ApiResponse<T>): response is ApiErrorResponse {
return response.success === false;
}
/**
* Type guard to check if an error is an ApiError instance
*/
export function isApiErrorInstance(error: unknown): error is ApiError {
return error instanceof ApiError;
}

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import {
Home,
Settings,
WaterDrop,
ExpandMore,
ExpandLess,
Menu,
@@ -106,6 +105,15 @@ export default function Sidebar({ setPage }: SidebarProps) {
Meters
</button>
</li>
<li>
<button
onClick={() => setPage("consumption")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Consumo
</button>
</li>
</ul>
)}
</li>

View File

@@ -117,7 +117,7 @@ export default function Home({
);
const filteredProjects = useMemo(
() => [...new Set(filteredMeters.map((m) => m.areaName))],
() => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[],
[filteredMeters]
);
@@ -125,7 +125,7 @@ export default function Home({
() =>
filteredProjects.map((projectName) => ({
name: projectName,
meterCount: filteredMeters.filter((m) => m.areaName === projectName)
meterCount: filteredMeters.filter((m) => m.projectName === projectName)
.length,
})),
[filteredProjects, filteredMeters]

View File

@@ -1,27 +1,26 @@
import { useMemo, useState } from "react";
import { Lock, User, Eye, EyeOff, Loader2, Check } from "lucide-react";
import { Lock, Mail, Eye, EyeOff, Loader2 } from "lucide-react";
import grhWatermark from "../assets/images/grhWatermark.png";
import { login } from "../api/auth";
type Form = { usuario: string; contrasena: string };
type Form = { email: string; password: string };
type LoginPageProps = {
onSuccess: (payload?: { token?: string }) => void;
onSuccess: () => void;
};
export default function LoginPage({ onSuccess }: LoginPageProps) {
const [form, setForm] = useState<Form>({ usuario: "", contrasena: "" });
const [form, setForm] = useState<Form>({ email: "", password: "" });
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [serverError, setServerError] = useState("");
const [notRobot, setNotRobot] = useState(false);
const errors = useMemo(() => {
const e: Partial<Record<keyof Form | "robot", string>> = {};
if (!form.usuario.trim()) e.usuario = "El usuario es obligatorio.";
if (!form.contrasena) e.contrasena = "La contraseña es obligatoria.";
if (!notRobot) e.robot = "Confirma que no eres un robot.";
const e: Partial<Record<keyof Form, string>> = {};
if (!form.email.trim()) e.email = "El correo es obligatorio.";
if (!form.password) e.password = "La contraseña es obligatoria.";
return e;
}, [form.usuario, form.contrasena, notRobot]);
}, [form.email, form.password]);
const canSubmit = Object.keys(errors).length === 0 && !loading;
@@ -30,12 +29,13 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
setServerError("");
if (!canSubmit) return;
try {
setLoading(true);
await new Promise((r) => setTimeout(r, 700));
onSuccess({ token: "demo" });
} catch {
setServerError("No se pudo iniciar sesión. Verifica tus datos.");
try {
await login({ email: form.email, password: form.password });
// Tokens are stored by the auth module
onSuccess();
} catch (err) {
setServerError(err instanceof Error ? err.message : "Error de autenticación");
} finally {
setLoading(false);
}
@@ -111,27 +111,28 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
</div>
)}
{/* Usuario */}
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-700">
Usuario
Correo electrónico
</label>
<div className="relative mt-2">
<input
value={form.usuario}
type="email"
value={form.email}
onChange={(e) =>
setForm((s) => ({ ...s, usuario: e.target.value }))
setForm((s) => ({ ...s, email: e.target.value }))
}
className="w-full border-b border-slate-300 py-2 pr-10 outline-none focus:border-slate-600"
/>
<User
<Mail
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
size={18}
/>
</div>
{errors.usuario && (
{errors.email && (
<p className="mt-1 text-xs text-red-600">
{errors.usuario}
{errors.email}
</p>
)}
</div>
@@ -143,9 +144,9 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
</label>
<div className="relative mt-2">
<input
value={form.contrasena}
value={form.password}
onChange={(e) =>
setForm((s) => ({ ...s, contrasena: e.target.value }))
setForm((s) => ({ ...s, password: e.target.value }))
}
type={showPass ? "text" : "password"}
className="w-full border-b border-slate-300 py-2 pr-16 outline-none focus:border-slate-600"
@@ -162,36 +163,13 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
size={18}
/>
</div>
{errors.contrasena && (
{errors.password && (
<p className="mt-1 text-xs text-red-600">
{errors.contrasena}
{errors.password}
</p>
)}
</div>
{/* NO SOY UN ROBOT */}
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<button
type="button"
onClick={() => setNotRobot((v) => !v)}
className={`h-5 w-5 rounded border flex items-center justify-center ${
notRobot
? "bg-blue-600 border-blue-600 text-white"
: "bg-white border-slate-300"
}`}
>
{notRobot && <Check size={14} />}
</button>
<span className="text-sm text-slate-700">No soy un robot</span>
<span className="ml-auto text-xs text-slate-400">
reCAPTCHA
</span>
</div>
{errors.robot && (
<p className="text-xs text-red-600">{errors.robot}</p>
)}
{/* Botón */}
<button
type="submit"

View File

@@ -1,381 +1,191 @@
// src/pages/concentrators/ConcentratorsModal.tsx
import type React from "react";
import type { Concentrator } from "../../api/concentrators";
import type { GatewayData } from "./ConcentratorsPage";
import { useEffect, useState } from "react";
import type { ConcentratorInput } from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
type Props = {
editingSerial: string | null;
form: Omit<Concentrator, "id">;
setForm: React.Dispatch<React.SetStateAction<Omit<Concentrator, "id">>>;
gatewayForm: GatewayData;
setGatewayForm: React.Dispatch<React.SetStateAction<GatewayData>>;
editingId: string | null;
form: ConcentratorInput;
setForm: React.Dispatch<React.SetStateAction<ConcentratorInput>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
toDatetimeLocalValue: (value?: string) => string;
fromDatetimeLocalValue: (value: string) => string;
allProjects: string[];
onClose: () => void;
onSave: () => void | Promise<void>;
};
export default function ConcentratorsModal({
editingSerial,
editingId,
form,
setForm,
gatewayForm,
setGatewayForm,
errors,
setErrors,
toDatetimeLocalValue,
fromDatetimeLocalValue,
onClose,
onSave,
}: Props) {
const title = editingSerial ? "Edit Concentrator" : "Add Concentrator";
const title = editingId ? "Editar Concentrador" : "Agregar Concentrador";
const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true);
useEffect(() => {
const load = async () => {
try {
const data = await fetchProjects();
setProjects(data);
} catch (error) {
console.error("Error loading projects:", error);
} finally {
setLoadingProjects(false);
}
};
load();
}, []);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[700px] max-h-[90vh] overflow-y-auto space-y-4">
<div className="bg-white rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold">{title}</h2>
{/* FORM */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Concentrator Information
Información del Concentrador
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-gray-50"
placeholder="Area Name"
value={form["Area Name"] ?? ""}
disabled
/>
<p className="text-xs text-gray-400 mt-1">
El proyecto seleccionado define el Area Name.
</p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device S/N"] ? "border-red-500" : ""
errors["serialNumber"] ? "border-red-500" : ""
}`}
placeholder="Device S/N *"
value={form["Device S/N"]}
placeholder="Número de serie"
value={form.serialNumber}
onChange={(e) => {
setForm({ ...form, "Device S/N": e.target.value });
if (errors["Device S/N"])
setErrors({ ...errors, "Device S/N": false });
setForm({ ...form, serialNumber: e.target.value });
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
}}
required
/>
{errors["Device S/N"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
{errors["serialNumber"] && (
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["name"] ? "border-red-500" : ""
}`}
placeholder="Nombre del concentrador"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
if (errors["name"]) setErrors({ ...errors, name: false });
}}
required
/>
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
</div>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Proyecto *</label>
<select
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["projectId"] ? "border-red-500" : ""
}`}
value={form.projectId}
onChange={(e) => {
setForm({ ...form, projectId: e.target.value });
if (errors["projectId"]) setErrors({ ...errors, projectId: false });
}}
disabled={loadingProjects}
required
>
<option value="">
{loadingProjects ? "Cargando..." : "Selecciona un proyecto"}
</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{errors["projectId"] && (
<p className="text-red-500 text-xs mt-1">Selecciona un proyecto</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación del concentrador (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device Name"] ? "border-red-500" : ""
}`}
placeholder="Device Name *"
value={form["Device Name"]}
onChange={(e) => {
setForm({ ...form, "Device Name": e.target.value });
if (errors["Device Name"])
setErrors({ ...errors, "Device Name": false });
}}
required
/>
{errors["Device Name"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Tipo *</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form["Device Status"]}
onChange={(e) =>
setForm({
...form,
"Device Status": e.target.value as any,
})
}
value={form.type ?? "LORA"}
onChange={(e) => setForm({ ...form, type: e.target.value as "LORA" | "LORAWAN" | "GRANDES" })}
>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="LORA">LoRa</option>
<option value="LORAWAN">LoRaWAN</option>
<option value="GRANDES">Grandes Consumidores</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Operator"] ? "border-red-500" : ""
}`}
placeholder="Operator *"
value={form["Operator"]}
onChange={(e) => {
setForm({ ...form, Operator: e.target.value });
if (errors["Operator"])
setErrors({ ...errors, Operator: false });
}}
required
/>
{errors["Operator"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
type="date"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Installed Time"] ? "border-red-500" : ""
}`}
value={(form["Installed Time"] ?? "").slice(0, 10)}
onChange={(e) => {
setForm({ ...form, "Installed Time": e.target.value });
if (errors["Installed Time"])
setErrors({ ...errors, "Installed Time": false });
}}
required
/>
{errors["Installed Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="datetime-local"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device Time"] ? "border-red-500" : ""
}`}
value={toDatetimeLocalValue(form["Device Time"])}
onChange={(e) => {
setForm({
...form,
"Device Time": fromDatetimeLocalValue(e.target.value),
});
if (errors["Device Time"])
setErrors({ ...errors, "Device Time": false });
}}
required
/>
{errors["Device Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
type="datetime-local"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Communication Time"] ? "border-red-500" : ""
}`}
value={toDatetimeLocalValue(form["Communication Time"])}
onChange={(e) => {
setForm({
...form,
"Communication Time": fromDatetimeLocalValue(e.target.value),
});
if (errors["Communication Time"])
setErrors({ ...errors, "Communication Time": false });
}}
required
/>
{errors["Communication Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Instruction Manual"] ? "border-red-500" : ""
}`}
placeholder="Instruction Manual *"
value={form["Instruction Manual"]}
onChange={(e) => {
setForm({ ...form, "Instruction Manual": e.target.value });
if (errors["Instruction Manual"])
setErrors({ ...errors, "Instruction Manual": false });
}}
required
/>
{errors["Instruction Manual"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
{/* GATEWAY */}
<div className="space-y-3 pt-4">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Gateway Configuration
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="number"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway ID"] ? "border-red-500" : ""
}`}
placeholder="Gateway ID *"
value={gatewayForm["Gateway ID"] || ""}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway ID": parseInt(e.target.value) || 0,
});
if (errors["Gateway ID"])
setErrors({ ...errors, "Gateway ID": false });
}}
required
min={1}
/>
{errors["Gateway ID"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway EUI"] ? "border-red-500" : ""
}`}
placeholder="Gateway EUI *"
value={gatewayForm["Gateway EUI"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway EUI": e.target.value,
});
if (errors["Gateway EUI"])
setErrors({ ...errors, "Gateway EUI": false });
}}
required
/>
{errors["Gateway EUI"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway Name"] ? "border-red-500" : ""
}`}
placeholder="Gateway Name *"
value={gatewayForm["Gateway Name"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway Name": e.target.value,
});
if (errors["Gateway Name"])
setErrors({ ...errors, "Gateway Name": false });
}}
required
/>
{errors["Gateway Name"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={gatewayForm["Antenna Placement"]}
onChange={(e) =>
setGatewayForm({
...gatewayForm,
"Antenna Placement": e.target.value as "Indoor" | "Outdoor",
})
}
value={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="Indoor">Indoor</option>
<option value="Outdoor">Outdoor</option>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="MAINTENANCE">Mantenimiento</option>
<option value="OFFLINE">Sin conexión</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Dirección IP</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway Description"] ? "border-red-500" : ""
}`}
placeholder="Gateway Description *"
value={gatewayForm["Gateway Description"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway Description": e.target.value,
});
if (errors["Gateway Description"])
setErrors({ ...errors, "Gateway Description": false });
}}
required
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="192.168.1.100"
value={form.ipAddress ?? ""}
onChange={(e) => setForm({ ...form, ipAddress: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Versión de Firmware</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="v1.0.0"
value={form.firmwareVersion ?? ""}
onChange={(e) => setForm({ ...form, firmwareVersion: e.target.value || undefined })}
/>
{errors["Gateway Description"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
{/* ACTIONS */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={onClose}
className="px-4 py-2 rounded hover:bg-gray-100"
>
Cancel
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
Cancelar
</button>
<button
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

View File

@@ -1,23 +1,23 @@
// src/pages/concentrators/ConcentratorsPage.tsx
import { useMemo, useState } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import ConfirmModal from "../../components/layout/common/ConfirmModal";
import { createConcentrator, deleteConcentrator, updateConcentrator, type Concentrator } from "../../api/concentrators";
// ✅ hook es named export y pide currentUser
import {
createConcentrator,
deleteConcentrator,
updateConcentrator,
type Concentrator,
type ConcentratorInput,
} from "../../api/concentrators";
import { useConcentrators } from "./useConcentrators";
// ✅ UI pieces
import ConcentratorsSidebar from "./ConcentratorsSidebar";
import ConcentratorsTable from "./ConcentratorsTable";
import ConcentratorsModal from "./ConcentratorsModal";
export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
export type ProjectStatus = "ACTIVO" | "INACTIVO";
export type ProjectCard = {
id: string;
name: string;
region: string;
projects: number;
@@ -33,91 +33,53 @@ type User = {
project?: string;
};
export type GatewayData = {
"Gateway ID": number;
"Gateway EUI": string;
"Gateway Name": string;
"Gateway Description": string;
"Antenna Placement": "Indoor" | "Outdoor";
concentratorId?: string;
};
export default function ConcentratorsPage() {
// ✅ Simulación de usuario actual
const currentUser: User = {
role: "SUPER_ADMIN",
project: "CESPT",
};
// ✅ Hook (solo cubre: projects + fetch + sampleView + selectedProject + loading + projectsData)
const c = useConcentrators(currentUser);
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeConcentrator, setActiveConcentrator] = useState<Concentrator | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingSerial, setEditingSerial] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const getEmptyConcentrator = (): Omit<Concentrator, "id"> => ({
"Area Name": c.selectedProject,
"Device S/N": "",
"Device Name": "",
"Device Time": new Date().toISOString(),
"Device Status": "ACTIVE",
Operator: "",
"Installed Time": new Date().toISOString().slice(0, 10),
"Communication Time": new Date().toISOString(),
"Instruction Manual": "",
const getEmptyForm = (): ConcentratorInput => ({
serialNumber: "",
name: "",
projectId: "",
location: "",
type: "LORA",
status: "ACTIVE",
ipAddress: "",
firmwareVersion: "",
});
const getEmptyGatewayData = (): GatewayData => ({
"Gateway ID": 0,
"Gateway EUI": "",
"Gateway Name": "",
"Gateway Description": "",
"Antenna Placement": "Indoor",
});
const [form, setForm] = useState<ConcentratorInput>(getEmptyForm());
const [errors, setErrors] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<Omit<Concentrator, "id">>(getEmptyConcentrator());
const [gatewayForm, setGatewayForm] = useState<GatewayData>(getEmptyGatewayData());
const [errors, setErrors] = useState<{ [key: string]: boolean }>({});
// ✅ Tabla filtrada por search (usa lo que YA filtró el hook por proyecto)
const searchFiltered = useMemo(() => {
if (!c.isGeneral) return [];
return c.filteredConcentrators.filter((row) => {
const q = search.trim().toLowerCase();
if (!q) return true;
const name = (row["Device Name"] ?? "").toLowerCase();
const sn = (row["Device S/N"] ?? "").toLowerCase();
const name = (row.name ?? "").toLowerCase();
const sn = (row.serialNumber ?? "").toLowerCase();
return name.includes(q) || sn.includes(q);
});
}, [c.filteredConcentrators, c.isGeneral, search]);
// =========================
// CRUD (solo GENERAL)
// =========================
const validateForm = () => {
const next: { [key: string]: boolean } = {};
const next: Record<string, boolean> = {};
if (!form["Device Name"].trim()) next["Device Name"] = true;
if (!form["Device S/N"].trim()) next["Device S/N"] = true;
if (!form["Operator"].trim()) next["Operator"] = true;
if (!form["Instruction Manual"].trim()) next["Instruction Manual"] = true;
if (!form["Installed Time"]) next["Installed Time"] = true;
if (!form["Device Time"]) next["Device Time"] = true;
if (!form["Communication Time"]) next["Communication Time"] = true;
if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) next["Gateway ID"] = true;
if (!gatewayForm["Gateway EUI"].trim()) next["Gateway EUI"] = true;
if (!gatewayForm["Gateway Name"].trim()) next["Gateway Name"] = true;
if (!gatewayForm["Gateway Description"].trim()) next["Gateway Description"] = true;
if (!form.name.trim()) next["name"] = true;
if (!form.serialNumber.trim()) next["serialNumber"] = true;
if (!form.projectId.trim()) next["projectId"] = true;
setErrors(next);
return Object.keys(next).length === 0;
@@ -128,23 +90,17 @@ export default function ConcentratorsPage() {
if (!validateForm()) return;
try {
if (editingSerial) {
const toUpdate = c.concentrators.find((x) => x["Device S/N"] === editingSerial);
if (!toUpdate) throw new Error("Concentrator not found");
const updated = await updateConcentrator(toUpdate.id, form);
// actualiza en memoria (el hook expone setConcentrators)
c.setConcentrators((prev) => prev.map((x) => (x.id === toUpdate.id ? updated : x)));
if (editingId) {
const updated = await updateConcentrator(editingId, form);
c.setConcentrators((prev) => prev.map((x) => (x.id === editingId ? updated : x)));
} else {
const created = await createConcentrator(form);
c.setConcentrators((prev) => [...prev, created]);
}
setShowModal(false);
setEditingSerial(null);
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
setGatewayForm(getEmptyGatewayData());
setEditingId(null);
setForm(getEmptyForm());
setErrors({});
setActiveConcentrator(null);
} catch (err) {
@@ -167,28 +123,32 @@ export default function ConcentratorsPage() {
}
};
// =========================
// Date helpers para modal
// =========================
function toDatetimeLocalValue(value?: string) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const mi = pad(d.getMinutes());
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
const openEditModal = () => {
if (!c.isGeneral || !activeConcentrator) return;
function fromDatetimeLocalValue(value: string) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return d.toISOString();
}
setEditingId(activeConcentrator.id);
setForm({
serialNumber: activeConcentrator.serialNumber,
name: activeConcentrator.name,
projectId: activeConcentrator.projectId,
location: activeConcentrator.location ?? "",
type: activeConcentrator.type ?? "LORA",
status: activeConcentrator.status,
ipAddress: activeConcentrator.ipAddress ?? "",
firmwareVersion: activeConcentrator.firmwareVersion ?? "",
});
setErrors({});
setShowModal(true);
};
const openCreateModal = () => {
if (!c.isGeneral) return;
setForm(getEmptyForm());
setErrors({});
setEditingId(null);
setShowModal(true);
};
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
@@ -202,8 +162,6 @@ export default function ConcentratorsPage() {
onChangeSampleView={(next: SampleView) => {
c.setSampleView(next);
setTypesMenuOpen(false);
// resets UI
c.setSelectedProject("");
setActiveConcentrator(null);
setSearch("");
@@ -238,46 +196,15 @@ export default function ConcentratorsPage() {
<div className="flex gap-3">
<button
onClick={() => {
if (!c.isGeneral) return;
if (!c.selectedProject) return;
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
setGatewayForm(getEmptyGatewayData());
setErrors({});
setEditingSerial(null);
setShowModal(true);
}}
disabled={!c.isGeneral || !c.selectedProject}
onClick={openCreateModal}
disabled={!c.isGeneral || c.allProjects.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} /> Agregar
</button>
<button
onClick={() => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
const a = activeConcentrator;
setEditingSerial(a["Device S/N"]);
setForm({
"Area Name": a["Area Name"],
"Device S/N": a["Device S/N"],
"Device Name": a["Device Name"],
"Device Time": a["Device Time"],
"Device Status": a["Device Status"],
Operator: a["Operator"],
"Installed Time": a["Installed Time"],
"Communication Time": a["Communication Time"],
"Instruction Manual": a["Instruction Manual"],
});
setGatewayForm(getEmptyGatewayData());
setErrors({});
setShowModal(true);
}}
onClick={openEditModal}
disabled={!c.isGeneral || !activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
@@ -304,7 +231,7 @@ export default function ConcentratorsPage() {
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder={c.isGeneral ? "Search concentrator..." : "Search disabled in mock views"}
placeholder={c.isGeneral ? "Buscar concentrador..." : "Search disabled in mock views"}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!c.isGeneral || !c.selectedProject}
@@ -320,10 +247,10 @@ export default function ConcentratorsPage() {
!c.isGeneral
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
: !c.selectedProject
? "Select a project to view concentrators."
? "Selecciona un proyecto para ver los concentradores."
: c.loadingConcentrators
? "Loading concentrators..."
: "No concentrators found. Click 'Add' to create your first concentrator."
? "Cargando concentradores..."
: "No hay concentradores. Haz clic en 'Agregar' para crear uno."
}
/>
</div>
@@ -332,8 +259,8 @@ export default function ConcentratorsPage() {
open={confirmOpen}
title="Eliminar concentrador"
message={`¿Estás seguro que quieres eliminar "${
activeConcentrator?.["Device Name"] ?? "este concentrador"
}"? Esta acción no se puede deshacer.`}
activeConcentrator?.name ?? "este concentrador"
}" (${activeConcentrator?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
confirmText="Eliminar"
cancelText="Cancelar"
danger
@@ -354,18 +281,14 @@ export default function ConcentratorsPage() {
{showModal && c.isGeneral && (
<ConcentratorsModal
editingSerial={editingSerial}
editingId={editingId}
form={form}
setForm={setForm}
gatewayForm={gatewayForm}
setGatewayForm={setGatewayForm}
errors={errors}
setErrors={setErrors}
toDatetimeLocalValue={toDatetimeLocalValue}
fromDatetimeLocalValue={fromDatetimeLocalValue}
allProjects={c.allProjects}
onClose={() => {
setShowModal(false);
setGatewayForm(getEmptyGatewayData());
setErrors({});
}}
onSave={handleSave}

View File

@@ -59,7 +59,9 @@ export default function ConcentratorsSidebar({
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
{" • "}
Seleccionado:{" "}
<span className="font-semibold">{selectedProject || "—"}</span>
<span className="font-semibold">
{projects.find((p) => p.id === selectedProject)?.name || "—"}
</span>
</p>
</div>
@@ -132,12 +134,12 @@ export default function ConcentratorsSidebar({
</div>
) : (
projects.map((p) => {
const active = p.name === selectedProject;
const active = p.id === selectedProject;
return (
<div
key={p.name}
onClick={() => onSelectProject(p.name)}
key={p.id}
onClick={() => onSelectProject(p.id)}
className={[
"rounded-xl border p-4 transition cursor-pointer",
active
@@ -211,7 +213,7 @@ export default function ConcentratorsSidebar({
].join(" ")}
onClick={(e) => {
e.stopPropagation();
onSelectProject(p.name);
onSelectProject(p.id);
}}
>
{active ? "Seleccionado" : "Seleccionar"}

View File

@@ -23,45 +23,69 @@ export default function ConcentratorsTable({
isLoading={isLoading}
columns={[
{
title: "Device Name",
field: "Device Name",
render: (rowData: any) => rowData["Device Name"] || "-",
title: "Serial",
field: "serialNumber",
render: (rowData: Concentrator) => rowData.serialNumber || "-",
},
{
title: "Device S/N",
field: "Device S/N",
render: (rowData: any) => rowData["Device S/N"] || "-",
title: "Nombre",
field: "name",
render: (rowData: Concentrator) => rowData.name || "-",
},
{
title: "Device Status",
field: "Device Status",
render: (rowData: any) => (
title: "Tipo",
field: "type",
render: (rowData: Concentrator) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = rowData.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (rowData: Concentrator) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
rowData["Device Status"] === "ACTIVE"
rowData.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{rowData["Device Status"] || "-"}
{rowData.status || "-"}
</span>
),
},
{
title: "Operator",
field: "Operator",
render: (rowData: any) => rowData["Operator"] || "-",
title: "Ubicación",
field: "location",
render: (rowData: Concentrator) => rowData.location || "-",
},
{
title: "Area Name",
field: "Area Name",
render: (rowData: any) => rowData["Area Name"] || "-",
title: "IP",
field: "ipAddress",
render: (rowData: Concentrator) => rowData.ipAddress || "-",
},
{
title: "Installed Time",
field: "Installed Time",
type: "date",
render: (rowData: any) => rowData["Installed Time"] || "-",
title: "Última Comunicación",
field: "lastCommunication",
type: "datetime",
render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-",
},
]}
data={data}

View File

@@ -3,6 +3,7 @@ import {
fetchConcentrators,
type Concentrator,
} from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
type User = {
@@ -16,6 +17,7 @@ export function useConcentrators(currentUser: User) {
const [loadingProjects, setLoadingProjects] = useState(true);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState("");
@@ -51,58 +53,49 @@ export function useConcentrators(currentUser: User) {
[allProjects, currentUser.role, currentUser.project]
);
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const raw = await fetchConcentrators();
const normalized = raw.map((c: any) => {
const preferredName =
c["Device Alias"] ||
c["Device Label"] ||
c["Device Display Name"] ||
c.deviceName ||
c.name ||
c["Device Name"] ||
"";
return {
...c,
"Device Name": preferredName,
};
});
const projectsArray = [
...new Set(normalized.map((r: any) => r["Area Name"])),
].filter(Boolean) as string[];
setAllProjects(projectsArray);
setConcentrators(normalized);
const projectsData = await fetchProjects();
setProjects(projectsData);
const projectIds = projectsData.map((p) => p.id);
setAllProjects(projectIds);
setSelectedProject((prev) => {
if (prev) return prev;
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
return currentUser.project;
}
return projectsArray[0] ?? "";
return projectIds[0] ?? "";
});
} catch (err) {
console.error("Error loading concentrators:", err);
console.error("Error loading projects:", err);
setProjects([]);
setAllProjects([]);
setConcentrators([]);
setSelectedProject("");
} finally {
setLoadingConcentrators(false);
setLoadingProjects(false);
}
};
// init
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
try {
const data = await fetchConcentrators();
setConcentrators(data);
} catch (err) {
console.error("Error loading concentrators:", err);
setConcentrators([]);
} finally {
setLoadingConcentrators(false);
}
};
// init - load projects and concentrators
useEffect(() => {
loadProjects();
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -110,6 +103,7 @@ export function useConcentrators(currentUser: User) {
// view changes
useEffect(() => {
if (isGeneral) {
loadProjects();
loadConcentrators();
} else {
setLoadingProjects(false);
@@ -136,7 +130,7 @@ export function useConcentrators(currentUser: User) {
if (selectedProject) {
setFilteredConcentrators(
concentrators.filter((c) => c["Area Name"] === selectedProject)
concentrators.filter((c) => c.projectId === selectedProject)
);
} else {
setFilteredConcentrators(concentrators);
@@ -146,8 +140,13 @@ export function useConcentrators(currentUser: User) {
// sidebar cards (general)
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
const area = c["Area Name"] ?? "SIN PROYECTO";
acc[area] = (acc[area] ?? 0) + 1;
const project = c.projectId ?? "SIN PROYECTO";
acc[project] = (acc[project] ?? 0) + 1;
return acc;
}, {});
const projectNameMap = projects.reduce<Record<string, string>>((acc, p) => {
acc[p.id] = p.name;
return acc;
}, {});
@@ -155,17 +154,18 @@ export function useConcentrators(currentUser: User) {
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
return visibleProjects.map((name) => ({
name,
return visibleProjects.map((projectId) => ({
id: projectId,
name: projectNameMap[projectId] ?? projectId,
region: baseRegion,
projects: 1,
concentrators: counts[name] ?? 0,
concentrators: counts[projectId] ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
status: "ACTIVO" as const,
}));
}, [concentrators, visibleProjects]);
}, [concentrators, visibleProjects, projects]);
// sidebar cards (mock)
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
@@ -173,6 +173,7 @@ export function useConcentrators(currentUser: User) {
() => ({
LORA: [
{
id: "mock-lora-centro",
name: "LoRa - Zona Centro",
region: "Baja California",
projects: 1,
@@ -183,6 +184,7 @@ export function useConcentrators(currentUser: User) {
status: "ACTIVO",
},
{
id: "mock-lora-este",
name: "LoRa - Zona Este",
region: "Baja California",
projects: 1,
@@ -195,6 +197,7 @@ export function useConcentrators(currentUser: User) {
],
LORAWAN: [
{
id: "mock-lorawan-industrial",
name: "LoRaWAN - Industrial",
region: "Baja California",
projects: 1,
@@ -207,6 +210,7 @@ export function useConcentrators(currentUser: User) {
],
GRANDES: [
{
id: "mock-grandes-convenios",
name: "Grandes - Convenios",
region: "Baja California",
projects: 1,

View File

@@ -0,0 +1,580 @@
import { useEffect, useState, useMemo } from "react";
import {
RefreshCcw,
Download,
Search,
Droplets,
TrendingUp,
Zap,
Clock,
ChevronLeft,
ChevronRight,
Filter,
X,
Activity,
Upload,
} from "lucide-react";
import {
fetchReadings,
fetchConsumptionSummary,
type MeterReading,
type ConsumptionSummary,
type Pagination,
} from "../../api/readings";
import { fetchProjects, type Project } from "../../api/projects";
import ReadingsBulkUploadModal from "./ReadingsBulkUploadModal";
export default function ConsumptionPage() {
const [readings, setReadings] = useState<MeterReading[]>([]);
const [summary, setSummary] = useState<ConsumptionSummary | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
pageSize: 100,
total: 0,
totalPages: 0,
});
const [loadingReadings, setLoadingReadings] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [selectedProject, setSelectedProject] = useState<string>("");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [search, setSearch] = useState<string>("");
const [showFilters, setShowFilters] = useState(false);
const [showBulkUpload, setShowBulkUpload] = useState(false);
useEffect(() => {
const loadProjects = async () => {
try {
const data = await fetchProjects();
setProjects(data);
} catch (error) {
console.error("Error loading projects:", error);
}
};
loadProjects();
}, []);
const loadData = async (page = 1) => {
setLoadingReadings(true);
setLoadingSummary(true);
try {
const [readingsResult, summaryResult] = await Promise.all([
fetchReadings({
projectId: selectedProject || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
page,
pageSize: 100,
}),
fetchConsumptionSummary(selectedProject || undefined),
]);
setReadings(readingsResult.data);
setPagination(readingsResult.pagination);
setSummary(summaryResult);
} catch (error) {
console.error("Error loading data:", error);
} finally {
setLoadingReadings(false);
setLoadingSummary(false);
}
};
useEffect(() => {
loadData(1);
}, [selectedProject, startDate, endDate]);
const filteredReadings = useMemo(() => {
if (!search.trim()) return readings;
const q = search.toLowerCase();
return readings.filter(
(r) =>
(r.meterSerialNumber ?? "").toLowerCase().includes(q) ||
(r.meterName ?? "").toLowerCase().includes(q) ||
(r.meterLocation ?? "").toLowerCase().includes(q) ||
String(r.readingValue).includes(q)
);
}, [readings, search]);
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
return date.toLocaleString("es-MX", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
};
const formatFullDate = (dateStr: string | null): string => {
if (!dateStr) return "Sin datos";
const date = new Date(dateStr);
return date.toLocaleString("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const exportToCSV = () => {
const headers = ["Fecha", "Medidor", "Serial", "Ubicación", "Valor", "Tipo", "Batería", "Señal"];
const rows = filteredReadings.map((r) => [
formatFullDate(r.receivedAt),
r.meterName || "—",
r.meterSerialNumber || "—",
r.meterLocation || "—",
Number(r.readingValue).toFixed(2),
r.readingType || "—",
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
r.signalStrength !== null ? `${r.signalStrength} dBm` : "—",
]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `consumo_${new Date().toISOString().split("T")[0]}.csv`;
link.click();
};
const clearFilters = () => {
setSelectedProject("");
setStartDate("");
setEndDate("");
setSearch("");
};
const hasFilters = selectedProject || startDate || endDate;
const activeFiltersCount = [selectedProject, startDate, endDate].filter(Boolean).length;
return (
<div className="min-h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 p-6">
<div className="max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800">Consumo de Agua</h1>
<p className="text-slate-500 text-sm mt-0.5">
Monitoreo en tiempo real de lecturas
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowBulkUpload(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-teal-600 rounded-xl hover:from-emerald-600 hover:to-teal-700 transition-all shadow-sm shadow-emerald-500/25"
>
<Upload size={16} />
Carga Masiva
</button>
<button
onClick={() => loadData(pagination.page)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
>
<RefreshCcw size={16} />
Actualizar
</button>
<button
onClick={exportToCSV}
disabled={filteredReadings.length === 0}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all shadow-sm shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={16} />
Exportar
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={<Activity />}
label="Total Lecturas"
value={summary?.totalReadings.toLocaleString() ?? "0"}
trend="+12%"
loading={loadingSummary}
gradient="from-blue-500 to-blue-600"
/>
<StatCard
icon={<Zap />}
label="Medidores Activos"
value={summary?.totalMeters.toLocaleString() ?? "0"}
loading={loadingSummary}
gradient="from-emerald-500 to-teal-600"
/>
<StatCard
icon={<Droplets />}
label="Consumo Promedio"
value={`${summary?.avgReading != null ? Number(summary.avgReading).toFixed(1) : "0"}`}
loading={loadingSummary}
gradient="from-violet-500 to-purple-600"
/>
<StatCard
icon={<Clock />}
label="Última Lectura"
value={summary?.lastReadingDate ? formatDate(summary.lastReadingDate) : "Sin datos"}
loading={loadingSummary}
gradient="from-amber-500 to-orange-600"
/>
</div>
{/* Table Card */}
<div className="bg-white rounded-2xl shadow-sm shadow-slate-200/50 border border-slate-200/60 overflow-hidden">
{/* Table Header */}
<div className="px-5 py-4 border-b border-slate-100 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="relative">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar lecturas..."
className="w-64 pl-10 pr-4 py-2 text-sm bg-slate-50 border-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:bg-white transition-all"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl transition-all ${
showFilters || hasFilters
? "bg-blue-50 text-blue-600 border border-blue-200"
: "text-slate-600 bg-slate-50 hover:bg-slate-100"
}`}
>
<Filter size={16} />
Filtros
{activeFiltersCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-blue-600 rounded-full">
{activeFiltersCount}
</span>
)}
</button>
{hasFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:text-slate-700"
>
<X size={14} />
Limpiar
</button>
)}
</div>
<div className="flex items-center gap-4 text-sm text-slate-500">
<span>
<span className="font-semibold text-slate-700">{filteredReadings.length}</span>{" "}
{pagination.total > filteredReadings.length && `de ${pagination.total} `}
lecturas
</span>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-1">
<button
onClick={() => loadData(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-1.5 rounded-md hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
<span className="px-2 text-xs font-medium">
{pagination.page} / {pagination.totalPages}
</span>
<button
onClick={() => loadData(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-1.5 rounded-md hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="px-5 py-4 bg-slate-50/50 border-b border-slate-100 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Proyecto
</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
<option value="">Todos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Desde
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Hasta
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50/80">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Fecha
</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Medidor
</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Serial
</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Ubicación
</th>
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
Consumo
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">
Tipo
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">
Estado
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loadingReadings ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-5 py-4">
<div className="h-4 bg-slate-100 rounded-md animate-pulse" />
</td>
))}
</tr>
))
) : filteredReadings.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-16 text-center">
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mb-4">
<Droplets size={32} className="text-slate-400" />
</div>
<p className="text-slate-600 font-medium">No hay lecturas disponibles</p>
<p className="text-slate-400 text-sm mt-1">
{hasFilters
? "Intenta ajustar los filtros de búsqueda"
: "Las lecturas aparecerán aquí cuando se reciban datos"}
</p>
</div>
</td>
</tr>
) : (
filteredReadings.map((reading, idx) => (
<tr
key={reading.id}
className={`group hover:bg-blue-50/40 transition-colors ${
idx % 2 === 0 ? "bg-white" : "bg-slate-50/30"
}`}
>
<td className="px-5 py-3.5">
<span className="text-sm text-slate-600">{formatDate(reading.receivedAt)}</span>
</td>
<td className="px-5 py-3.5">
<span className="text-sm font-medium text-slate-800">
{reading.meterName || "—"}
</span>
</td>
<td className="px-5 py-3.5">
<code className="text-xs text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
{reading.meterSerialNumber || "—"}
</code>
</td>
<td className="px-5 py-3.5">
<span className="text-sm text-slate-600">{reading.meterLocation || "—"}</span>
</td>
<td className="px-5 py-3.5 text-right">
<span className="text-sm font-semibold text-slate-800 tabular-nums">
{Number(reading.readingValue).toFixed(2)}
</span>
<span className="text-xs text-slate-400 ml-1">m³</span>
</td>
<td className="px-5 py-3.5 text-center">
<TypeBadge type={reading.readingType} />
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center gap-2">
<BatteryIndicator level={reading.batteryLevel} />
<SignalIndicator strength={reading.signalStrength} />
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
{showBulkUpload && (
<ReadingsBulkUploadModal
onClose={() => setShowBulkUpload(false)}
onSuccess={() => {
loadData(1);
setShowBulkUpload(false);
}}
/>
)}
</div>
);
}
function StatCard({
icon,
label,
value,
trend,
loading,
gradient,
}: {
icon: React.ReactNode;
label: string;
value: string;
trend?: string;
loading?: boolean;
gradient: string;
}) {
return (
<div className="relative bg-white rounded-2xl p-5 shadow-sm shadow-slate-200/50 border border-slate-200/60 overflow-hidden group hover:shadow-md hover:shadow-slate-200/50 transition-all">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-500">{label}</p>
{loading ? (
<div className="h-8 w-24 bg-slate-100 rounded-lg animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-800">{value}</p>
)}
{trend && !loading && (
<div className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
<TrendingUp size={12} />
{trend}
</div>
)}
</div>
<div
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${gradient} flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform`}
>
{icon}
</div>
</div>
<div
className={`absolute -right-8 -bottom-8 w-32 h-32 rounded-full bg-gradient-to-br ${gradient} opacity-5`}
/>
</div>
);
}
function TypeBadge({ type }: { type: string | null }) {
if (!type) return <span className="text-slate-400"></span>;
const styles: Record<string, { bg: string; text: string; dot: string }> = {
AUTOMATIC: { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" },
MANUAL: { bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-500" },
SCHEDULED: { bg: "bg-violet-50", text: "text-violet-700", dot: "bg-violet-500" },
};
const style = styles[type] || { bg: "bg-slate-50", text: "text-slate-700", dot: "bg-slate-500" };
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${style.bg} ${style.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${style.dot}`} />
{type}
</span>
);
}
function BatteryIndicator({ level }: { level: number | null }) {
if (level === null) return null;
const getColor = () => {
if (level > 50) return "bg-emerald-500";
if (level > 20) return "bg-amber-500";
return "bg-red-500";
};
return (
<div className="flex items-center gap-1" title={`Batería: ${level}%`}>
<div className="w-6 h-3 border border-slate-300 rounded-sm relative overflow-hidden">
<div
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
style={{ width: `${level}%` }}
/>
</div>
<span className="text-[10px] text-slate-500 font-medium">{level}%</span>
</div>
);
}
function SignalIndicator({ strength }: { strength: number | null }) {
if (strength === null) return null;
const getBars = () => {
if (strength >= -70) return 4;
if (strength >= -85) return 3;
if (strength >= -100) return 2;
return 1;
};
const bars = getBars();
return (
<div className="flex items-end gap-0.5 h-3" title={`Señal: ${strength} dBm`}>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={`w-1 rounded-sm transition-colors ${
i <= bars ? "bg-emerald-500" : "bg-slate-200"
}`}
style={{ height: `${i * 2 + 4}px` }}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useState, useRef } from "react";
import { Upload, Download, X, AlertCircle, CheckCircle } from "lucide-react";
import { bulkUploadReadings, downloadReadingTemplate, type BulkUploadResult } from "../../api/readings";
type Props = {
onClose: () => void;
onSuccess: () => void;
};
export default function ReadingsBulkUploadModal({ onClose, onSuccess }: Props) {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<BulkUploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
// Validate file type
const validTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (!validTypes.includes(selectedFile.type)) {
setError("Solo se permiten archivos Excel (.xlsx, .xls)");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResult(null);
try {
const uploadResult = await bulkUploadReadings(file);
setResult(uploadResult);
if (uploadResult.data.inserted > 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Error en la carga");
} finally {
setUploading(false);
}
};
const handleDownloadTemplate = async () => {
try {
await downloadReadingTemplate();
} catch (err) {
setError(err instanceof Error ? err.message : "Error descargando plantilla");
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[600px] max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Carga Masiva de Lecturas</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={20} />
</button>
</div>
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 className="font-medium text-blue-800 mb-2">Instrucciones:</h3>
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
<li>Descarga la plantilla Excel con el formato correcto</li>
<li>Llena los datos de las lecturas (meter_serial y reading_value son obligatorios)</li>
<li>El meter_serial debe coincidir con un medidor existente</li>
<li>Sube el archivo Excel completado</li>
</ol>
</div>
{/* Download Template Button */}
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 mb-4"
>
<Download size={16} />
Descargar Plantilla Excel
</button>
{/* File Input */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 mb-4 text-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".xlsx,.xls"
className="hidden"
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="text-green-500" size={20} />
<span className="text-gray-700">{file.name}</span>
<button
onClick={() => {
setFile(null);
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="text-red-500 hover:text-red-700 ml-2"
>
<X size={16} />
</button>
</div>
) : (
<div>
<Upload className="mx-auto text-gray-400 mb-2" size={32} />
<p className="text-gray-600 mb-2">
Arrastra un archivo Excel aquí o
</p>
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-800 font-medium"
>
selecciona un archivo
</button>
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 flex items-start gap-2">
<AlertCircle className="text-red-500 shrink-0" size={20} />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{/* Upload Result */}
{result && (
<div
className={`border rounded-lg p-4 mb-4 ${
result.success
? "bg-green-50 border-green-200"
: "bg-yellow-50 border-yellow-200"
}`}
>
<h4 className="font-medium mb-2">
{result.success ? "Carga completada" : "Carga completada con errores"}
</h4>
<div className="text-sm space-y-1">
<p>Total de filas: {result.data.totalRows}</p>
<p className="text-green-600">Insertadas: {result.data.inserted}</p>
{result.data.failed > 0 && (
<p className="text-red-600">Fallidas: {result.data.failed}</p>
)}
</div>
{/* Error Details */}
{result.data.errors.length > 0 && (
<div className="mt-3">
<h5 className="font-medium text-sm mb-2">Errores:</h5>
<div className="max-h-40 overflow-y-auto bg-white rounded border p-2">
{result.data.errors.map((err, idx) => (
<div key={idx} className="text-xs text-red-600 py-1 border-b last:border-0">
Fila {err.row}: {err.error}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={onClose}
className="px-4 py-2 rounded hover:bg-gray-100"
>
{result ? "Cerrar" : "Cancelar"}
</button>
{!result && (
<button
onClick={handleUpload}
disabled={!file || uploading}
className="flex items-center gap-2 bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e] disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<>
<span className="animate-spin"></span>
Cargando...
</>
) : (
<>
<Upload size={16} />
Cargar Lecturas
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import type { Meter } from "../../api/meters";
import { Plus, Trash2, Pencil, RefreshCcw, Upload } from "lucide-react";
import type { Meter, MeterInput } from "../../api/meters";
import { createMeter, deleteMeter, updateMeter } from "../../api/meters";
import ConfirmModal from "../../components/layout/common/ConfirmModal";
@@ -8,16 +8,9 @@ import { useMeters } from "./useMeters";
import MetersSidebar from "./MetersSidebar";
import MetersTable from "./MetersTable";
import MetersModal from "./MetersModal";
import MetersBulkUploadModal from "./MetersBulkUploadModal";
/* ================= TYPES (exportables para otros componentes) ================= */
export interface DeviceData {
"Device ID": number;
"Device EUI": string;
"Join EUI": string;
AppKey: string;
meterId?: string;
}
/* ================= TYPES ================= */
export type ProjectStatus = "ACTIVO" | "INACTIVO";
@@ -34,20 +27,6 @@ export type ProjectCard = {
export type TakeType = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
/* ================= MOCKS (sin backend) ================= */
const MOCK_PROJECTS_BY_TYPE: Record<
Exclude<TakeType, "GENERAL">,
Array<{ name: string; meters?: number }>
> = {
LORA: [
{ name: "LoRa - Demo 01", meters: 12 },
{ name: "LoRa - Demo 02", meters: 7 },
],
LORAWAN: [{ name: "LoRaWAN - Demo 01", meters: 4 }],
GRANDES: [{ name: "Grandes - Demo 01", meters: 2 }],
};
/* ================= COMPONENT ================= */
export default function MetersPage({
@@ -68,46 +47,27 @@ export default function MetersPage({
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const emptyMeter: Omit<Meter, "id"> = useMemo(
const [showBulkUpload, setShowBulkUpload] = useState(false);
// Form state for creating/editing meters
const emptyForm: MeterInput = useMemo(
() => ({
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
areaName: "",
accountNumber: null,
userName: null,
userAddress: null,
meterSerialNumber: "",
meterName: "",
meterStatus: "Installed",
protocolType: "",
priceNo: null,
priceName: null,
dmaPartition: null,
supplyTypes: "",
deviceId: "",
deviceName: "",
deviceType: "",
usageAnalysisType: "",
installedTime: new Date().toISOString(),
serialNumber: "",
meterId: "",
name: "",
concentratorId: "",
location: "",
type: "LORA",
status: "ACTIVE",
installationDate: new Date().toISOString(),
}),
[]
);
const emptyDeviceData: DeviceData = useMemo(
() => ({
"Device ID": 0,
"Device EUI": "",
"Join EUI": "",
AppKey: "",
}),
[]
);
const [form, setForm] = useState<Omit<Meter, "id">>(emptyMeter);
const [deviceForm, setDeviceForm] = useState<DeviceData>(emptyDeviceData);
const [form, setForm] = useState<MeterInput>(emptyForm);
const [errors, setErrors] = useState<Record<string, boolean>>({});
// Projects cards (real)
// Projects cards (from real data)
const projectsDataReal: ProjectCard[] = useMemo(() => {
const baseRegion = "Baja California";
const baseContact = "Operaciones";
@@ -121,32 +81,13 @@ export default function MetersPage({
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
status: "ACTIVO" as ProjectStatus,
}));
}, [m.allProjects, m.projectsCounts]);
// Projects cards (mock)
const projectsDataMock: ProjectCard[] = useMemo(() => {
const baseRegion = "Baja California";
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
const sidebarProjects = isMockMode ? [] : projectsDataReal;
const mocks = MOCK_PROJECTS_BY_TYPE[takeType as Exclude<TakeType, "GENERAL">] ?? [];
return mocks.map((x) => ({
name: x.name,
region: baseRegion,
projects: 1,
meters: x.meters ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
}));
}, [takeType]);
const sidebarProjects = isMockMode ? projectsDataMock : projectsDataReal;
// Search filtered
// Search filtered meters
const searchFiltered = useMemo(() => {
if (isMockMode) return [];
const q = search.trim().toLowerCase();
@@ -154,76 +95,43 @@ export default function MetersPage({
return m.filteredMeters.filter((x) => {
return (
(x.meterName ?? "").toLowerCase().includes(q) ||
(x.meterSerialNumber ?? "").toLowerCase().includes(q) ||
(x.deviceId ?? "").toLowerCase().includes(q) ||
(x.areaName ?? "").toLowerCase().includes(q)
(x.name ?? "").toLowerCase().includes(q) ||
(x.serialNumber ?? "").toLowerCase().includes(q) ||
(x.location ?? "").toLowerCase().includes(q) ||
(x.concentratorName ?? "").toLowerCase().includes(q)
);
});
}, [isMockMode, search, m.filteredMeters]);
// Device config mock
const createOrUpdateDevice = async (deviceData: DeviceData): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Device data that would be sent to API:", deviceData);
resolve();
}, 500);
});
};
// Validation
const validateForm = (): boolean => {
const next: Record<string, boolean> = {};
if (!form.meterName.trim()) next["meterName"] = true;
if (!form.meterSerialNumber.trim()) next["meterSerialNumber"] = true;
if (!form.areaName.trim()) next["areaName"] = true;
if (!form.deviceName.trim()) next["deviceName"] = true;
if (!form.protocolType.trim()) next["protocolType"] = true;
if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) next["Device ID"] = true;
if (!deviceForm["Device EUI"].trim()) next["Device EUI"] = true;
if (!deviceForm["Join EUI"].trim()) next["Join EUI"] = true;
if (!deviceForm["AppKey"].trim()) next["AppKey"] = true;
if (!form.name.trim()) next["name"] = true;
if (!form.serialNumber.trim()) next["serialNumber"] = true;
if (!form.concentratorId.trim()) next["concentratorId"] = true;
setErrors(next);
return Object.keys(next).length === 0;
};
// CRUD
// CRUD handlers
const handleSave = async () => {
if (isMockMode) return;
if (!validateForm()) return;
try {
let savedMeter: Meter;
if (editingId) {
const meterToUpdate = m.meters.find((x) => x.id === editingId);
if (!meterToUpdate) throw new Error("Meter to update not found");
const updatedMeter = await updateMeter(editingId, form);
m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x)));
savedMeter = updatedMeter;
} else {
const newMeter = await createMeter(form);
m.setMeters((prev) => [...prev, newMeter]);
savedMeter = newMeter;
}
try {
const deviceDataWithRef = { ...deviceForm, meterId: savedMeter.id };
await createOrUpdateDevice(deviceDataWithRef);
} catch (deviceError) {
console.error("Error saving device data:", deviceError);
alert("Meter saved, but there was an error saving device data.");
}
setShowModal(false);
setEditingId(null);
setForm(emptyMeter);
setDeviceForm(emptyDeviceData);
setForm(emptyForm);
setErrors({});
setActiveMeter(null);
} catch (error) {
@@ -260,6 +168,33 @@ export default function MetersPage({
setSearch("");
};
const openEditModal = () => {
if (isMockMode || !activeMeter) return;
setEditingId(activeMeter.id);
setForm({
serialNumber: activeMeter.serialNumber,
meterId: activeMeter.meterId ?? "",
name: activeMeter.name,
concentratorId: activeMeter.concentratorId,
location: activeMeter.location ?? "",
type: activeMeter.type,
status: activeMeter.status,
installationDate: activeMeter.installationDate ?? "",
});
setErrors({});
setShowModal(true);
};
const openCreateModal = () => {
if (isMockMode) return;
setForm(emptyForm);
setErrors({});
setEditingId(null);
setShowModal(true);
};
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
{/* SIDEBAR */}
@@ -296,55 +231,23 @@ export default function MetersPage({
<div className="flex gap-3">
<button
onClick={() => {
if (isMockMode) return;
const base = { ...emptyMeter };
if (m.selectedProject) base.areaName = m.selectedProject;
setForm(base);
setDeviceForm(emptyDeviceData);
setErrors({});
setEditingId(null);
setShowModal(true);
}}
disabled={isMockMode || !m.selectedProject || m.allProjects.length === 0}
onClick={openCreateModal}
disabled={isMockMode || m.allProjects.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} /> Agregar
</button>
<button
onClick={() => {
if (isMockMode) return;
if (!activeMeter) return;
onClick={() => setShowBulkUpload(true)}
disabled={isMockMode}
className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-green-600"
>
<Upload size={16} /> Carga Masiva
</button>
setEditingId(activeMeter.id);
setForm({
createdAt: activeMeter.createdAt,
updatedAt: activeMeter.updatedAt,
areaName: activeMeter.areaName,
accountNumber: activeMeter.accountNumber,
userName: activeMeter.userName,
userAddress: activeMeter.userAddress,
meterSerialNumber: activeMeter.meterSerialNumber,
meterName: activeMeter.meterName,
meterStatus: activeMeter.meterStatus,
protocolType: activeMeter.protocolType,
priceNo: activeMeter.priceNo,
priceName: activeMeter.priceName,
dmaPartition: activeMeter.dmaPartition,
supplyTypes: activeMeter.supplyTypes,
deviceId: activeMeter.deviceId,
deviceName: activeMeter.deviceName,
deviceType: activeMeter.deviceType,
usageAnalysisType: activeMeter.usageAnalysisType,
installedTime: activeMeter.installedTime,
});
setDeviceForm(emptyDeviceData);
setErrors({});
setShowModal(true);
}}
<button
onClick={openEditModal}
disabled={isMockMode || !activeMeter}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
@@ -373,7 +276,7 @@ export default function MetersPage({
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder="Search by meter name, serial number, device ID, area, device type, or meter status..."
placeholder="Buscar por nombre, serial, ubicación o concentrador..."
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={isMockMode || !m.selectedProject}
@@ -392,8 +295,8 @@ export default function MetersPage({
open={confirmOpen}
title="Eliminar medidor"
message={`¿Estás seguro que quieres eliminar "${
activeMeter?.meterName ?? "este medidor"
}" (${activeMeter?.meterSerialNumber ?? "—"})? Esta acción no se puede deshacer.`}
activeMeter?.name ?? "este medidor"
}" (${activeMeter?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
confirmText="Eliminar"
cancelText="Cancelar"
danger
@@ -416,18 +319,27 @@ export default function MetersPage({
editingId={editingId}
form={form}
setForm={setForm}
deviceForm={deviceForm}
setDeviceForm={setDeviceForm}
errors={errors}
setErrors={setErrors}
onClose={() => {
setShowModal(false);
setDeviceForm(emptyDeviceData);
setErrors({});
}}
onSave={handleSave}
/>
)}
{showBulkUpload && (
<MetersBulkUploadModal
onClose={() => {
m.loadMeters();
setShowBulkUpload(false);
}}
onSuccess={() => {
m.loadMeters();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useState, useRef } from "react";
import { Upload, Download, X, AlertCircle, CheckCircle } from "lucide-react";
import { bulkUploadMeters, downloadMeterTemplate, type BulkUploadResult } from "../../api/meters";
type Props = {
onClose: () => void;
onSuccess: () => void;
};
export default function MetersBulkUploadModal({ onClose, onSuccess }: Props) {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<BulkUploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
// Validate file type
const validTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (!validTypes.includes(selectedFile.type)) {
setError("Solo se permiten archivos Excel (.xlsx, .xls)");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResult(null);
try {
const uploadResult = await bulkUploadMeters(file);
setResult(uploadResult);
if (uploadResult.data.inserted > 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Error en la carga");
} finally {
setUploading(false);
}
};
const handleDownloadTemplate = async () => {
try {
await downloadMeterTemplate();
} catch (err) {
setError(err instanceof Error ? err.message : "Error descargando plantilla");
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[600px] max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Carga Masiva de Medidores</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={20} />
</button>
</div>
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 className="font-medium text-blue-800 mb-2">Instrucciones:</h3>
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
<li>Descarga la plantilla Excel con el formato correcto</li>
<li>Llena los datos de los medidores (serial_number, name y concentrator_serial son obligatorios)</li>
<li>El concentrator_serial debe coincidir con un concentrador existente</li>
<li>Sube el archivo Excel completado</li>
</ol>
</div>
{/* Download Template Button */}
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 mb-4"
>
<Download size={16} />
Descargar Plantilla Excel
</button>
{/* File Input */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 mb-4 text-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".xlsx,.xls"
className="hidden"
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="text-green-500" size={20} />
<span className="text-gray-700">{file.name}</span>
<button
onClick={() => {
setFile(null);
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="text-red-500 hover:text-red-700 ml-2"
>
<X size={16} />
</button>
</div>
) : (
<div>
<Upload className="mx-auto text-gray-400 mb-2" size={32} />
<p className="text-gray-600 mb-2">
Arrastra un archivo Excel aquí o
</p>
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-800 font-medium"
>
selecciona un archivo
</button>
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 flex items-start gap-2">
<AlertCircle className="text-red-500 shrink-0" size={20} />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{/* Upload Result */}
{result && (
<div
className={`border rounded-lg p-4 mb-4 ${
result.success
? "bg-green-50 border-green-200"
: "bg-yellow-50 border-yellow-200"
}`}
>
<h4 className="font-medium mb-2">
{result.success ? "Carga completada" : "Carga completada con errores"}
</h4>
<div className="text-sm space-y-1">
<p>Total de filas: {result.data.totalRows}</p>
<p className="text-green-600">Insertados: {result.data.inserted}</p>
{result.data.failed > 0 && (
<p className="text-red-600">Fallidos: {result.data.failed}</p>
)}
</div>
{/* Error Details */}
{result.data.errors.length > 0 && (
<div className="mt-3">
<h5 className="font-medium text-sm mb-2">Errores:</h5>
<div className="max-h-40 overflow-y-auto bg-white rounded border p-2">
{result.data.errors.map((err, idx) => (
<div key={idx} className="text-xs text-red-600 py-1 border-b last:border-0">
Fila {err.row}: {err.error}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={onClose}
className="px-4 py-2 rounded hover:bg-gray-100"
>
{result ? "Cerrar" : "Cancelar"}
</button>
{!result && (
<button
onClick={handleUpload}
disabled={!file || uploading}
className="flex items-center gap-2 bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e] disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<>
<span className="animate-spin"></span>
Cargando...
</>
) : (
<>
<Upload size={16} />
Cargar Medidores
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,13 @@
import type React from "react";
import type { Meter } from "../../api/meters";
import type { DeviceData } from "./MeterPage";
import { useEffect, useState } from "react";
import type { MeterInput } from "../../api/meters";
import { fetchConcentrators, type Concentrator } from "../../api/concentrators";
type Props = {
editingId: string | null;
form: Omit<Meter, "id">;
setForm: React.Dispatch<React.SetStateAction<Omit<Meter, "id">>>;
deviceForm: DeviceData;
setDeviceForm: React.Dispatch<React.SetStateAction<DeviceData>>;
form: MeterInput;
setForm: React.Dispatch<React.SetStateAction<MeterInput>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
@@ -22,245 +20,183 @@ export default function MetersModal({
editingId,
form,
setForm,
deviceForm,
setDeviceForm,
errors,
setErrors,
onClose,
onSave,
}: Props) {
const title = editingId ? "Edit Meter" : "Add Meter";
const title = editingId ? "Editar Medidor" : "Agregar Medidor";
const [concentrators, setConcentrators] = useState<Concentrator[]>([]);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
// Load concentrators for the dropdown
useEffect(() => {
const load = async () => {
try {
const data = await fetchConcentrators();
setConcentrators(data);
} catch (error) {
console.error("Error loading concentrators:", error);
} finally {
setLoadingConcentrators(false);
}
};
load();
}, []);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[700px] max-h-[90vh] overflow-y-auto space-y-4">
<div className="bg-white rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold">{title}</h2>
{/* FORM */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Meter Information
Información del Medidor
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["areaName"] ? "border-red-500" : ""
errors["serialNumber"] ? "border-red-500" : ""
}`}
placeholder="Area Name *"
value={form.areaName}
placeholder="Número de serie"
value={form.serialNumber}
onChange={(e) => {
setForm({ ...form, areaName: e.target.value });
if (errors["areaName"]) setErrors({ ...errors, areaName: false });
setForm({ ...form, serialNumber: e.target.value });
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
}}
required
/>
{errors["areaName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Account Number (optional)"
value={form.accountNumber ?? ""}
onChange={(e) =>
setForm({ ...form, accountNumber: e.target.value || null })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="User Name (optional)"
value={form.userName ?? ""}
onChange={(e) =>
setForm({ ...form, userName: e.target.value || null })
}
/>
</div>
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="User Address (optional)"
value={form.userAddress ?? ""}
onChange={(e) =>
setForm({ ...form, userAddress: e.target.value || null })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["meterSerialNumber"] ? "border-red-500" : ""
}`}
placeholder="Meter S/N *"
value={form.meterSerialNumber}
onChange={(e) => {
setForm({ ...form, meterSerialNumber: e.target.value });
if (errors["meterSerialNumber"])
setErrors({ ...errors, meterSerialNumber: false });
}}
required
/>
{errors["meterSerialNumber"] && (
<p className="text-red-500 text-xs mt-1">This field is required</p>
{errors["serialNumber"] && (
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
)}
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["meterName"] ? "border-red-500" : ""
}`}
placeholder="Meter Name *"
value={form.meterName}
onChange={(e) => {
setForm({ ...form, meterName: e.target.value });
if (errors["meterName"]) setErrors({ ...errors, meterName: false });
}}
required
/>
{errors["meterName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["protocolType"] ? "border-red-500" : ""
}`}
placeholder="Protocol Type *"
value={form.protocolType}
onChange={(e) => {
setForm({ ...form, protocolType: e.target.value });
if (errors["protocolType"]) setErrors({ ...errors, protocolType: false });
}}
required
/>
{errors["protocolType"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Meter ID</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Device ID (optional)"
value={form.deviceId ?? ""}
onChange={(e) => setForm({ ...form, deviceId: e.target.value || "" })}
placeholder="ID del medidor (opcional)"
value={form.meterId ?? ""}
onChange={(e) => setForm({ ...form, meterId: e.target.value || undefined })}
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["deviceName"] ? "border-red-500" : ""
errors["name"] ? "border-red-500" : ""
}`}
placeholder="Device Name *"
value={form.deviceName}
placeholder="Nombre del medidor"
value={form.name}
onChange={(e) => {
setForm({ ...form, deviceName: e.target.value });
if (errors["deviceName"]) setErrors({ ...errors, deviceName: false });
setForm({ ...form, name: e.target.value });
if (errors["name"]) setErrors({ ...errors, name: false });
}}
required
/>
{errors["deviceName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
</div>
{/* DEVICE CONFIG */}
<div className="space-y-3 pt-4">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Device Configuration
</h3>
<div>
<label className="block text-sm text-gray-600 mb-1">Concentrador *</label>
<select
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["concentratorId"] ? "border-red-500" : ""
}`}
value={form.concentratorId}
onChange={(e) => {
setForm({ ...form, concentratorId: e.target.value });
if (errors["concentratorId"]) setErrors({ ...errors, concentratorId: false });
}}
disabled={loadingConcentrators}
required
>
<option value="">
{loadingConcentrators ? "Cargando..." : "Selecciona un concentrador"}
</option>
{concentrators.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.serialNumber})
</option>
))}
</select>
{errors["concentratorId"] && (
<p className="text-red-500 text-xs mt-1">Selecciona un concentrador</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación del medidor (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="number"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device ID"] ? "border-red-500" : ""
}`}
placeholder="Device ID *"
value={deviceForm["Device ID"] || ""}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Device ID": parseInt(e.target.value) || 0 });
if (errors["Device ID"]) setErrors({ ...errors, "Device ID": false });
}}
required
min={1}
/>
{errors["Device ID"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
<label className="block text-sm text-gray-600 mb-1">Tipo</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.type ?? "LORA"}
onChange={(e) => setForm({ ...form, type: e.target.value })}
>
<option value="LORA">LoRa</option>
<option value="LORAWAN">LoRaWAN</option>
<option value="GRANDES">Grandes Consumidores</option>
</select>
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device EUI"] ? "border-red-500" : ""
}`}
placeholder="Device EUI *"
value={deviceForm["Device EUI"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Device EUI": e.target.value });
if (errors["Device EUI"]) setErrors({ ...errors, "Device EUI": false });
}}
required
/>
{errors["Device EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="MAINTENANCE">Mantenimiento</option>
<option value="FAULTY">Averiado</option>
<option value="REPLACED">Reemplazado</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Fecha de Instalación</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Join EUI"] ? "border-red-500" : ""
}`}
placeholder="Join EUI *"
value={deviceForm["Join EUI"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Join EUI": e.target.value });
if (errors["Join EUI"]) setErrors({ ...errors, "Join EUI": false });
}}
required
type="date"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.installationDate?.split("T")[0] ?? ""}
onChange={(e) =>
setForm({
...form,
installationDate: e.target.value ? new Date(e.target.value).toISOString() : undefined,
})
}
/>
{errors["Join EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["AppKey"] ? "border-red-500" : ""
}`}
placeholder="AppKey *"
value={deviceForm["AppKey"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, AppKey: e.target.value });
if (errors["AppKey"]) setErrors({ ...errors, AppKey: false });
}}
required
/>
{errors["AppKey"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
</div>
{/* ACTIONS */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
Cancel
Cancelar
</button>
<button
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

View File

@@ -28,15 +28,51 @@ export default function MetersTable({
title="Meters"
isLoading={isLoading}
columns={[
{ title: "Area Name", field: "areaName", render: (r: any) => r.areaName || "-" },
{ title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" },
{ title: "User Name", field: "userName", render: (r: any) => r.userName || "-" },
{ title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" },
{ title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" },
{ title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" },
{ title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" },
{ title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" },
{ title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" },
{ title: "Serial", field: "serialNumber", render: (r: Meter) => r.serialNumber || "-" },
{ title: "Meter ID", field: "meterId", render: (r: Meter) => r.meterId || "-" },
{ title: "Nombre", field: "name", render: (r: Meter) => r.name || "-" },
{ title: "Ubicación", field: "location", render: (r: Meter) => r.location || "-" },
{
title: "Tipo",
field: "type",
render: (r: Meter) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = r.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (r: Meter) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
r.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{r.status || "-"}
</span>
),
},
{ title: "Concentrador", field: "concentratorName", render: (r: Meter) => r.concentratorName || "-" },
{ title: "Última Lectura", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-" },
]}
data={disabled ? [] : data}
onRowClick={(_, rowData) => onRowClick(rowData as Meter)}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { fetchMeters, type Meter } from "../../api/meters";
import { fetchProjects } from "../../api/projects";
type UseMetersArgs = {
initialProject?: string;
@@ -15,37 +16,43 @@ export function useMeters({ initialProject }: UseMetersArgs) {
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
const [loadingMeters, setLoadingMeters] = useState(true);
const loadMeters = async () => {
setLoadingMeters(true);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const data = await fetchMeters();
const projectsArray = [...new Set(data.map((r) => r.areaName))]
.filter(Boolean) as string[];
setAllProjects(projectsArray);
setMeters(data);
const projects = await fetchProjects();
const projectNames = projects.map((p) => p.name);
setAllProjects(projectNames);
setSelectedProject((prev) => {
if (prev) return prev;
if (initialProject) return initialProject;
return projectsArray[0] ?? "";
return projectNames[0] ?? "";
});
} catch (error) {
console.error("Error loading meters:", error);
console.error("Error loading projects:", error);
setAllProjects([]);
setMeters([]);
setSelectedProject("");
} finally {
setLoadingMeters(false);
setLoadingProjects(false);
}
};
// init
const loadMeters = async () => {
setLoadingMeters(true);
try {
const data = await fetchMeters();
setMeters(data);
} catch (error) {
console.error("Error loading meters:", error);
setMeters([]);
} finally {
setLoadingMeters(false);
}
};
// init - load projects and meters
useEffect(() => {
loadProjects();
loadMeters();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -61,13 +68,13 @@ export function useMeters({ initialProject }: UseMetersArgs) {
setFilteredMeters([]);
return;
}
setFilteredMeters(meters.filter((m) => m.areaName === selectedProject));
setFilteredMeters(meters.filter((m) => m.projectName === selectedProject));
}, [selectedProject, meters]);
const projectsCounts = useMemo(() => {
return meters.reduce<Record<string, number>>((acc, m) => {
const area = m.areaName ?? "SIN PROYECTO";
acc[area] = (acc[area] ?? 0) + 1;
const project = m.projectName ?? "SIN PROYECTO";
acc[project] = (acc[project] ?? 0) + 1;
return acc;
}, {});
}, [meters]);

View File

@@ -3,13 +3,13 @@ import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import {
Project,
ProjectInput,
fetchProjects,
createProject as apiCreateProject,
updateProject as apiUpdateProject,
deleteProject as apiDeleteProject,
} from "../../api/projects";
/* ================= COMPONENT ================= */
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
@@ -19,20 +19,16 @@ export default function ProjectsPage() {
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const emptyProject: Omit<Project, "id"> = {
const emptyForm: ProjectInput = {
name: "",
description: "",
areaName: "",
deviceSN: "",
deviceName: "",
deviceType: "",
deviceStatus: "ACTIVE",
operator: "",
installedTime: "",
communicationTime: "",
location: "",
status: "ACTIVE",
};
const [form, setForm] = useState<Omit<Project, "id">>(emptyProject);
const [form, setForm] = useState<ProjectInput>(emptyForm);
/* ================= LOAD ================= */
const loadProjects = async () => {
setLoading(true);
try {
@@ -50,7 +46,6 @@ export default function ProjectsPage() {
loadProjects();
}, []);
const handleSave = async () => {
try {
if (editingId) {
@@ -65,7 +60,7 @@ export default function ProjectsPage() {
setShowModal(false);
setEditingId(null);
setForm(emptyProject);
setForm(emptyForm);
setActiveProject(null);
} catch (error) {
console.error("Error saving project:", error);
@@ -81,7 +76,7 @@ export default function ProjectsPage() {
if (!activeProject) return;
const confirmDelete = window.confirm(
`Are you sure you want to delete the project "${activeProject.deviceName}"?`
`¿Estás seguro que quieres eliminar el proyecto "${activeProject.name}"?`
);
if (!confirmDelete) return;
@@ -100,14 +95,31 @@ export default function ProjectsPage() {
}
};
/* ================= FILTER ================= */
const openEditModal = () => {
if (!activeProject) return;
setEditingId(activeProject.id);
setForm({
name: activeProject.name,
description: activeProject.description ?? "",
areaName: activeProject.areaName,
location: activeProject.location ?? "",
status: activeProject.status,
});
setShowModal(true);
};
const openCreateModal = () => {
setForm(emptyForm);
setEditingId(null);
setShowModal(true);
};
const filtered = projects.filter((p) =>
`${p.areaName} ${p.deviceName} ${p.deviceSN}`
`${p.name} ${p.areaName} ${p.description ?? ""}`
.toLowerCase()
.includes(search.toLowerCase())
);
/* ================= UI ================= */
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
<div className="flex-1 flex flex-col gap-6">
@@ -120,41 +132,23 @@ export default function ProjectsPage() {
>
<div>
<h1 className="text-2xl font-bold">Project Management</h1>
<p className="text-sm text-blue-100">Projects registered</p>
<p className="text-sm text-blue-100">Proyectos registrados</p>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setForm(emptyProject);
setEditingId(null);
setShowModal(true);
}}
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Add
<Plus size={16} /> Agregar
</button>
<button
onClick={() => {
if (!activeProject) return;
setEditingId(activeProject.id);
setForm({
areaName: activeProject.areaName,
deviceSN: activeProject.deviceSN,
deviceName: activeProject.deviceName,
deviceType: activeProject.deviceType,
deviceStatus: activeProject.deviceStatus,
operator: activeProject.operator,
installedTime: activeProject.installedTime,
communicationTime: activeProject.communicationTime,
});
setShowModal(true);
}}
onClick={openEditModal}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Edit
<Pencil size={16} /> Editar
</button>
<button
@@ -162,14 +156,14 @@ export default function ProjectsPage() {
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Delete
<Trash2 size={16} /> Eliminar
</button>
<button
onClick={loadProjects}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
>
<RefreshCcw size={16} /> Refresh
<RefreshCcw size={16} /> Actualizar
</button>
</div>
</div>
@@ -177,38 +171,40 @@ export default function ProjectsPage() {
{/* SEARCH */}
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder="Search project..."
placeholder="Buscar proyecto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* TABLE */}
<MaterialTable
title="Projects"
title="Proyectos"
isLoading={loading}
columns={[
{ title: "Area Name", field: "areaName" },
{ title: "Device S/N", field: "deviceSN" },
{ title: "Device Name", field: "deviceName" },
{ title: "Device Type", field: "deviceType" },
{ title: "Nombre", field: "name" },
{ title: "Area", field: "areaName" },
{ title: "Descripción", field: "description", render: (rowData: Project) => rowData.description || "-" },
{ title: "Ubicación", field: "location", render: (rowData: Project) => rowData.location || "-" },
{
title: "Status",
field: "deviceStatus",
render: (rowData) => (
title: "Estado",
field: "status",
render: (rowData: Project) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
rowData.deviceStatus === "ACTIVE"
rowData.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{rowData.deviceStatus}
{rowData.status}
</span>
),
},
{ title: "Operator", field: "operator" },
{ title: "Installed Time", field: "installedTime" },
{ title: "Communication Name", field: "communicationTime" },
{
title: "Creado",
field: "createdAt",
render: (rowData: Project) => new Date(rowData.createdAt).toLocaleDateString(),
},
]}
data={filtered}
onRowClick={(_, rowData) => setActiveProject(rowData as Project)}
@@ -226,8 +222,8 @@ export default function ProjectsPage() {
localization={{
body: {
emptyDataSourceMessage: loading
? "Loading projects..."
: "No projects found. Click 'Add' to create your first project.",
? "Cargando proyectos..."
: "No hay proyectos. Haz clic en 'Agregar' para crear uno.",
},
}}
/>
@@ -235,85 +231,78 @@ export default function ProjectsPage() {
{/* MODAL */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-xl p-6 w-96 space-y-3">
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[450px] space-y-4">
<h2 className="text-lg font-semibold">
{editingId ? "Edit Project" : "Add Project"}
{editingId ? "Editar Proyecto" : "Agregar Proyecto"}
</h2>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Area Name"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del proyecto"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Area *</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del area"
value={form.areaName}
onChange={(e) => setForm({ ...form, areaName: e.target.value })}
/>
</div>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Device S/N"
value={form.deviceSN}
onChange={(e) => setForm({ ...form, deviceSN: e.target.value })}
<div>
<label className="block text-sm text-gray-600 mb-1">Descripción</label>
<textarea
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Descripción del proyecto (opcional)"
rows={3}
value={form.description ?? ""}
onChange={(e) => setForm({ ...form, description: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Device Name"
value={form.deviceName}
onChange={(e) => setForm({ ...form, deviceName: e.target.value })}
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Device Type"
value={form.deviceType}
onChange={(e) => setForm({ ...form, deviceType: e.target.value })}
/>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Operator"
value={form.operator}
onChange={(e) => setForm({ ...form, operator: e.target.value })}
/>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Installed Time"
value={form.installedTime}
onChange={(e) =>
setForm({ ...form, installedTime: e.target.value })
}
/>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Communication Time"
value={form.communicationTime}
onChange={(e) =>
setForm({ ...form, communicationTime: e.target.value })
}
/>
<button
onClick={() =>
setForm({
...form,
deviceStatus:
form.deviceStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE",
})
}
className="w-full border rounded px-3 py-2"
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
Status: {form.deviceStatus}
</button>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="SUSPENDED">Suspendido</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-3">
<button onClick={() => setShowModal(false)}>Cancel</button>
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded hover:bg-gray-100"
>
Cancelar
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded"
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

26
water-api/.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Server Configuration
PORT=3000
NODE_ENV=development
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=water_db
DB_USER=postgres
DB_PASSWORD=your_password_here
# JWT Configuration
JWT_ACCESS_SECRET=your_access_secret_key_here
JWT_REFRESH_SECRET=your_refresh_secret_key_here
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# CORS Configuration
CORS_ORIGIN=http://localhost:5173
# TTS (Third-party Telemetry Service) Configuration
TTS_ENABLED=false
TTS_BASE_URL=https://api.tts-service.com
TTS_APPLICATION_ID=your_application_id_here
TTS_API_KEY=your_api_key_here
TTS_WEBHOOK_SECRET=your_webhook_secret_here

86
water-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,86 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build output
dist/
build/
*.js.map
# Environment variables
.env
.env.local
.env.development
.env.test
.env.production
.env*.local
# IDE and editors
.idea/
.vscode/
*.swp
*.swo
*.swn
*~
.project
.classpath
.settings/
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Testing
coverage/
.nyc_output/
*.lcov
# TypeScript cache
*.tsbuildinfo
tsconfig.tsbuildinfo
# Temporary files
tmp/
temp/
.tmp/
.temp/
*.tmp
*.temp
# Debug
.npm
.eslintcache
.stylelintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Docker
.docker/
# Miscellaneous
*.pid
*.seed
*.pid.lock

45
water-api/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "water-api",
"version": "1.0.0",
"description": "Water Management System API",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"watch": "nodemon --exec ts-node src/index.ts"
},
"keywords": [
"water",
"management",
"api",
"express"
],
"author": "",
"license": "ISC",
"dependencies": {
"@types/multer": "^2.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"pg": "^8.11.3",
"winston": "^3.11.0",
"xlsx": "^0.18.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.11.5",
"@types/pg": "^8.10.9",
"nodemon": "^3.0.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
}
}

434
water-api/sql/schema.sql Normal file
View File

@@ -0,0 +1,434 @@
-- ============================================================================
-- Water Project Database Schema
-- PostgreSQL Migration Script
-- ============================================================================
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================================
-- TRIGGER FUNCTION: Auto-update updated_at timestamp
-- ============================================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- ENUM TYPES
-- ============================================================================
CREATE TYPE role_name AS ENUM ('ADMIN', 'OPERATOR', 'VIEWER');
CREATE TYPE project_status AS ENUM ('ACTIVE', 'INACTIVE', 'COMPLETED');
CREATE TYPE device_status AS ENUM ('ACTIVE', 'INACTIVE', 'OFFLINE', 'MAINTENANCE', 'ERROR');
CREATE TYPE meter_type AS ENUM ('WATER', 'GAS', 'ELECTRIC');
CREATE TYPE reading_type AS ENUM ('AUTOMATIC', 'MANUAL', 'SCHEDULED');
-- ============================================================================
-- TABLE 1: roles
-- ============================================================================
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name role_name NOT NULL UNIQUE,
description TEXT,
permissions JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_roles_name ON roles(name);
CREATE TRIGGER trigger_roles_updated_at
BEFORE UPDATE ON roles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE roles IS 'User roles with associated permissions';
-- ============================================================================
-- TABLE 2: users
-- ============================================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
avatar_url TEXT,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role_id ON users(role_id);
CREATE INDEX idx_users_is_active ON users(is_active);
CREATE TRIGGER trigger_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE users IS 'Application users with authentication credentials';
-- ============================================================================
-- TABLE 3: projects
-- ============================================================================
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
area_name VARCHAR(255),
location TEXT,
status project_status NOT NULL DEFAULT 'ACTIVE',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_created_by ON projects(created_by);
CREATE INDEX idx_projects_name ON projects(name);
CREATE TRIGGER trigger_projects_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE projects IS 'Water monitoring projects';
-- ============================================================================
-- TABLE 4: concentrators
-- ============================================================================
CREATE TABLE concentrators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
serial_number VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
location TEXT,
status device_status NOT NULL DEFAULT 'ACTIVE',
ip_address INET,
firmware_version VARCHAR(50),
last_communication TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_concentrators_serial_number ON concentrators(serial_number);
CREATE INDEX idx_concentrators_project_id ON concentrators(project_id);
CREATE INDEX idx_concentrators_status ON concentrators(status);
CREATE TRIGGER trigger_concentrators_updated_at
BEFORE UPDATE ON concentrators
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE concentrators IS 'Data concentrators that aggregate gateway communications';
-- ============================================================================
-- TABLE 5: gateways
-- ============================================================================
CREATE TABLE gateways (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_id VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
concentrator_id UUID REFERENCES concentrators(id) ON DELETE SET NULL,
location TEXT,
status device_status NOT NULL DEFAULT 'ACTIVE',
tts_gateway_id VARCHAR(255),
tts_status VARCHAR(50),
tts_last_seen TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_gateways_gateway_id ON gateways(gateway_id);
CREATE INDEX idx_gateways_project_id ON gateways(project_id);
CREATE INDEX idx_gateways_concentrator_id ON gateways(concentrator_id);
CREATE INDEX idx_gateways_status ON gateways(status);
CREATE TRIGGER trigger_gateways_updated_at
BEFORE UPDATE ON gateways
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE gateways IS 'LoRaWAN gateways for device communication';
-- ============================================================================
-- TABLE 6: devices
-- ============================================================================
CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dev_eui VARCHAR(16) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
device_type VARCHAR(100),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
gateway_id UUID REFERENCES gateways(id) ON DELETE SET NULL,
status device_status NOT NULL DEFAULT 'ACTIVE',
tts_device_id VARCHAR(255),
tts_status VARCHAR(50),
tts_last_seen TIMESTAMP WITH TIME ZONE,
app_key VARCHAR(32),
join_eui VARCHAR(16),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_devices_dev_eui ON devices(dev_eui);
CREATE INDEX idx_devices_project_id ON devices(project_id);
CREATE INDEX idx_devices_gateway_id ON devices(gateway_id);
CREATE INDEX idx_devices_status ON devices(status);
CREATE INDEX idx_devices_device_type ON devices(device_type);
CREATE TRIGGER trigger_devices_updated_at
BEFORE UPDATE ON devices
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE devices IS 'LoRaWAN end devices (sensors/transmitters)';
-- ============================================================================
-- TABLE 7: meters
-- ============================================================================
CREATE TABLE meters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
serial_number VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
area_name VARCHAR(255),
location TEXT,
meter_type meter_type NOT NULL DEFAULT 'WATER',
status device_status NOT NULL DEFAULT 'ACTIVE',
last_reading_value NUMERIC(15, 4),
last_reading_at TIMESTAMP WITH TIME ZONE,
installation_date DATE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_meters_serial_number ON meters(serial_number);
CREATE INDEX idx_meters_project_id ON meters(project_id);
CREATE INDEX idx_meters_device_id ON meters(device_id);
CREATE INDEX idx_meters_status ON meters(status);
CREATE INDEX idx_meters_meter_type ON meters(meter_type);
CREATE INDEX idx_meters_area_name ON meters(area_name);
CREATE TRIGGER trigger_meters_updated_at
BEFORE UPDATE ON meters
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE meters IS 'Physical water meters associated with devices';
-- ============================================================================
-- TABLE 8: meter_readings
-- ============================================================================
CREATE TABLE meter_readings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meter_id UUID NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
reading_value NUMERIC(15, 4) NOT NULL,
reading_type reading_type NOT NULL DEFAULT 'AUTOMATIC',
battery_level SMALLINT,
signal_strength SMALLINT,
raw_payload TEXT,
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_meter_readings_meter_id ON meter_readings(meter_id);
CREATE INDEX idx_meter_readings_device_id ON meter_readings(device_id);
CREATE INDEX idx_meter_readings_received_at ON meter_readings(received_at);
CREATE INDEX idx_meter_readings_meter_id_received_at ON meter_readings(meter_id, received_at DESC);
COMMENT ON TABLE meter_readings IS 'Historical meter reading values';
-- ============================================================================
-- TABLE 9: tts_uplink_logs
-- ============================================================================
CREATE TABLE tts_uplink_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
dev_eui VARCHAR(16) NOT NULL,
raw_payload JSONB NOT NULL,
decoded_payload JSONB,
gateway_ids TEXT[],
rssi SMALLINT,
snr NUMERIC(5, 2),
processed BOOLEAN NOT NULL DEFAULT FALSE,
error_message TEXT,
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tts_uplink_logs_device_id ON tts_uplink_logs(device_id);
CREATE INDEX idx_tts_uplink_logs_dev_eui ON tts_uplink_logs(dev_eui);
CREATE INDEX idx_tts_uplink_logs_received_at ON tts_uplink_logs(received_at);
CREATE INDEX idx_tts_uplink_logs_processed ON tts_uplink_logs(processed);
CREATE INDEX idx_tts_uplink_logs_raw_payload ON tts_uplink_logs USING GIN (raw_payload);
COMMENT ON TABLE tts_uplink_logs IS 'The Things Stack uplink message logs';
-- ============================================================================
-- TABLE 10: refresh_tokens
-- ============================================================================
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for user sessions';
-- ============================================================================
-- VIEW: meter_stats_by_project
-- ============================================================================
CREATE OR REPLACE VIEW meter_stats_by_project AS
SELECT
p.id AS project_id,
p.name AS project_name,
p.status AS project_status,
COUNT(m.id) AS total_meters,
COUNT(CASE WHEN m.status = 'ACTIVE' THEN 1 END) AS active_meters,
COUNT(CASE WHEN m.status = 'INACTIVE' THEN 1 END) AS inactive_meters,
COUNT(CASE WHEN m.status = 'OFFLINE' THEN 1 END) AS offline_meters,
COUNT(CASE WHEN m.status = 'MAINTENANCE' THEN 1 END) AS maintenance_meters,
COUNT(CASE WHEN m.status = 'ERROR' THEN 1 END) AS error_meters,
ROUND(AVG(m.last_reading_value)::NUMERIC, 2) AS avg_last_reading,
MAX(m.last_reading_at) AS most_recent_reading,
COUNT(DISTINCT m.area_name) AS unique_areas
FROM projects p
LEFT JOIN meters m ON p.id = m.project_id
GROUP BY p.id, p.name, p.status;
COMMENT ON VIEW meter_stats_by_project IS 'Aggregated meter statistics per project';
-- ============================================================================
-- VIEW: device_status_summary
-- ============================================================================
CREATE OR REPLACE VIEW device_status_summary AS
SELECT
p.id AS project_id,
p.name AS project_name,
'concentrator' AS device_category,
c.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN concentrators c ON p.id = c.project_id
WHERE c.id IS NOT NULL
GROUP BY p.id, p.name, c.status
UNION ALL
SELECT
p.id AS project_id,
p.name AS project_name,
'gateway' AS device_category,
g.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN gateways g ON p.id = g.project_id
WHERE g.id IS NOT NULL
GROUP BY p.id, p.name, g.status
UNION ALL
SELECT
p.id AS project_id,
p.name AS project_name,
'device' AS device_category,
d.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN devices d ON p.id = d.project_id
WHERE d.id IS NOT NULL
GROUP BY p.id, p.name, d.status
UNION ALL
SELECT
p.id AS project_id,
p.name AS project_name,
'meter' AS device_category,
m.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN meters m ON p.id = m.project_id
WHERE m.id IS NOT NULL
GROUP BY p.id, p.name, m.status;
COMMENT ON VIEW device_status_summary IS 'Summary of device statuses across all device types per project';
-- ============================================================================
-- SEED DATA: Default Roles
-- ============================================================================
INSERT INTO roles (name, description, permissions) VALUES
(
'ADMIN',
'Full system administrator with all permissions',
'{
"users": {"create": true, "read": true, "update": true, "delete": true},
"projects": {"create": true, "read": true, "update": true, "delete": true},
"devices": {"create": true, "read": true, "update": true, "delete": true},
"meters": {"create": true, "read": true, "update": true, "delete": true},
"readings": {"create": true, "read": true, "update": true, "delete": true},
"settings": {"create": true, "read": true, "update": true, "delete": true},
"reports": {"create": true, "read": true, "export": true}
}'::JSONB
),
(
'OPERATOR',
'Operator with management permissions but no system settings',
'{
"users": {"create": false, "read": true, "update": false, "delete": false},
"projects": {"create": true, "read": true, "update": true, "delete": false},
"devices": {"create": true, "read": true, "update": true, "delete": false},
"meters": {"create": true, "read": true, "update": true, "delete": false},
"readings": {"create": true, "read": true, "update": false, "delete": false},
"settings": {"create": false, "read": true, "update": false, "delete": false},
"reports": {"create": true, "read": true, "export": true}
}'::JSONB
),
(
'VIEWER',
'Read-only access to view data and reports',
'{
"users": {"create": false, "read": false, "update": false, "delete": false},
"projects": {"create": false, "read": true, "update": false, "delete": false},
"devices": {"create": false, "read": true, "update": false, "delete": false},
"meters": {"create": false, "read": true, "update": false, "delete": false},
"readings": {"create": false, "read": true, "update": false, "delete": false},
"settings": {"create": false, "read": false, "update": false, "delete": false},
"reports": {"create": false, "read": true, "export": false}
}'::JSONB
);
-- ============================================================================
-- SEED DATA: Default Admin User
-- Password: admin123 (bcrypt hashed)
-- ============================================================================
INSERT INTO users (email, password_hash, name, role_id, is_active)
SELECT
'admin@waterproject.com',
'$2b$12$RrlEdRsUiiQYxtUmjOjX.uZU/IpXUFsXsWxDcMny1RUl6RFc.etDm',
'System Administrator',
r.id,
TRUE
FROM roles r
WHERE r.name = 'ADMIN';
-- ============================================================================
-- END OF SCHEMA
-- ============================================================================

View File

View File

@@ -0,0 +1,113 @@
import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
import config from './index';
import logger from '../utils/logger';
const pool = new Pool({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
database: config.database.database,
ssl: config.database.ssl ? { rejectUnauthorized: false } : false,
max: config.database.maxConnections,
idleTimeoutMillis: config.database.idleTimeoutMs,
connectionTimeoutMillis: config.database.connectionTimeoutMs,
});
pool.on('connect', () => {
logger.debug('New client connected to the database pool');
});
pool.on('error', (err: Error) => {
logger.error('Unexpected error on idle database client', err);
});
pool.on('remove', () => {
logger.debug('Client removed from the database pool');
});
/**
* Execute a query on the database pool
* @param text - SQL query string
* @param params - Query parameters
* @returns Query result
*/
export const query = async <T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> => {
const start = Date.now();
try {
const result = await pool.query<T>(text, params);
const duration = Date.now() - start;
logger.debug(`Query executed in ${duration}ms`, {
query: text,
rows: result.rowCount,
});
return result;
} catch (error) {
logger.error('Database query error', {
query: text,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};
/**
* Get a client from the pool for transactions
* @returns Pool client
*/
export const getClient = async (): Promise<PoolClient> => {
try {
const client = await pool.connect();
logger.debug('Database client acquired from pool');
return client;
} catch (error) {
logger.error('Failed to get database client', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};
/**
* Test database connectivity
* @returns True if connection is successful, false otherwise
*/
export const testConnection = async (): Promise<boolean> => {
try {
const result = await pool.query('SELECT NOW()');
logger.info('Database connection successful', {
serverTime: result.rows[0]?.now,
});
return true;
} catch (error) {
logger.error('Database connection failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return false;
}
};
/**
* Close all pool connections (for graceful shutdown)
*/
export const closePool = async (): Promise<void> => {
try {
await pool.end();
logger.info('Database pool closed');
} catch (error) {
logger.error('Error closing database pool', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};
export { pool };
export default pool;

View File

@@ -0,0 +1,128 @@
import dotenv from 'dotenv';
dotenv.config();
interface ServerConfig {
port: number;
env: string;
isProduction: boolean;
isDevelopment: boolean;
}
interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
ssl: boolean;
maxConnections: number;
idleTimeoutMs: number;
connectionTimeoutMs: number;
}
interface JwtConfig {
accessTokenSecret: string;
refreshTokenSecret: string;
accessTokenExpiresIn: string;
refreshTokenExpiresIn: string;
}
interface CorsConfig {
origin: string | string[];
credentials: boolean;
methods: string[];
allowedHeaders: string[];
}
interface TtsConfig {
enabled: boolean;
apiKey: string;
apiUrl: string;
applicationId: string;
webhookSecret: string;
requireWebhookVerification: boolean;
}
interface Config {
server: ServerConfig;
database: DatabaseConfig;
jwt: JwtConfig;
cors: CorsConfig;
tts: TtsConfig;
}
const requiredEnvVars = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
];
const validateEnvVars = (): void => {
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}`
);
}
};
const parseOrigin = (origin: string | undefined): string | string[] => {
if (!origin) return '*';
if (origin.includes(',')) {
return origin.split(',').map((o) => o.trim());
}
return origin;
};
const getConfig = (): Config => {
validateEnvVars();
return {
server: {
port: parseInt(process.env.PORT || '3000', 10),
env: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
isDevelopment: process.env.NODE_ENV !== 'production',
},
database: {
host: process.env.DB_HOST!,
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
ssl: process.env.DB_SSL === 'true',
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20', 10),
idleTimeoutMs: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMs: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10),
},
jwt: {
accessTokenSecret: process.env.JWT_ACCESS_SECRET!,
refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
accessTokenExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
refreshTokenExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
cors: {
origin: parseOrigin(process.env.CORS_ORIGIN),
credentials: process.env.CORS_CREDENTIALS === 'true',
methods: (process.env.CORS_METHODS || 'GET,POST,PUT,DELETE,PATCH,OPTIONS').split(','),
allowedHeaders: (process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization').split(','),
},
tts: {
enabled: process.env.TTS_ENABLED === 'true',
apiKey: process.env.TTS_API_KEY || '',
apiUrl: process.env.TTS_API_URL || '',
applicationId: process.env.TTS_APPLICATION_ID || '',
webhookSecret: process.env.TTS_WEBHOOK_SECRET || '',
requireWebhookVerification: process.env.TTS_REQUIRE_WEBHOOK_VERIFICATION !== 'false',
},
};
};
export const config = getConfig();
export default config;

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,147 @@
import { Request, Response } from 'express';
import multer from 'multer';
import {
bulkUploadMeters,
generateMeterTemplate,
bulkUploadReadings,
generateReadingTemplate,
} 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 - check both MIME type and extension
const allowedMimes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
'application/octet-stream', // Generic binary (some systems send this)
];
const allowedExtensions = ['.xlsx', '.xls'];
const fileExtension = file.originalname.toLowerCase().slice(file.originalname.lastIndexOf('.'));
if (allowedMimes.includes(file.mimetype) || allowedExtensions.includes(fileExtension)) {
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',
});
}
}
/**
* POST /api/bulk-upload/readings
* Upload Excel file with readings data
*/
export async function uploadReadings(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 bulkUploadReadings(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 readings bulk upload:', error);
res.status(500).json({
success: false,
error: error.message || 'Error procesando la carga masiva de lecturas',
});
}
}
/**
* GET /api/bulk-upload/readings/template
* Download Excel template for readings
*/
export async function downloadReadingTemplate(_req: Request, res: Response): Promise<void> {
try {
const buffer = generateReadingTemplate();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename=plantilla_lecturas.xlsx');
res.send(buffer);
} catch (err) {
const error = err as Error;
console.error('Error generating readings template:', error);
res.status(500).json({
success: false,
error: 'Error generando la plantilla de lecturas',
});
}
}

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) || 50, 1000);
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,
});
}
}

97
water-api/src/index.ts Normal file
View File

@@ -0,0 +1,97 @@
import dotenv from 'dotenv';
dotenv.config();
import express, { Application, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import routes from './routes';
import logger from './utils/logger';
import { testConnection } from './config/database';
const app: Application = express();
const PORT = process.env.PORT || 3000;
const NODE_ENV = process.env.NODE_ENV || 'development';
// Security middleware
app.use(helmet());
// CORS configuration
const allowedOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173')
.split(',')
.map(origin => origin.trim());
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
logger.warn(`CORS blocked origin: ${origin}`);
callback(null, true); // Allow all in development
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Health check endpoint
app.get('/health', (_req: Request, res: Response) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
environment: NODE_ENV
});
});
// Mount all API routes
app.use('/api', routes);
// 404 handler
app.use((_req: Request, res: Response) => {
res.status(404).json({
success: false,
message: 'Resource not found',
error: 'NOT_FOUND'
});
});
// Global error handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Error:', err);
res.status(500).json({
success: false,
message: NODE_ENV === 'development' ? err.message : 'Internal server error',
error: 'INTERNAL_ERROR'
});
});
// Start server
const startServer = async () => {
try {
// Test database connection
await testConnection();
logger.info('Database connection established');
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT} in ${NODE_ENV} mode`);
logger.info(`Health check available at http://localhost:${PORT}/health`);
logger.info(`API available at http://localhost:${PORT}/api`);
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
}
};
startServer();
export default app;

View File

View File

@@ -0,0 +1,84 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
/**
* Extended Request interface with authenticated user
*/
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: string;
};
}
/**
* Middleware to authenticate JWT access tokens
* Extracts Bearer token from Authorization header, verifies it,
* and attaches the decoded user to the request object
*/
export function authenticateToken(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).json({ error: 'Authorization header missing' });
return;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
res.status(401).json({ error: 'Invalid authorization header format' });
return;
}
const token = parts[1];
try {
const decoded = verifyAccessToken(token);
if (!decoded) {
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role,
};
next();
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
/**
* Middleware factory for role-based access control
* Checks if the authenticated user has one of the required roles
* @param roles - Array of allowed roles
*/
export function requireRole(...roles: string[]) {
return (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ error: 'Insufficient permissions' });
return;
}
next();
};
}

View File

@@ -0,0 +1,293 @@
import { Response, NextFunction } from 'express';
import crypto from 'crypto';
import logger from '../utils/logger';
import { TtsWebhookRequest } from '../controllers/tts.controller';
/**
* TTS webhook verification configuration
*/
interface TtsWebhookConfig {
webhookSecret: string | undefined;
apiKey: string | undefined;
requireVerification: boolean;
}
/**
* Get TTS webhook configuration from environment
*/
function getTtsWebhookConfig(): TtsWebhookConfig {
return {
webhookSecret: process.env.TTS_WEBHOOK_SECRET,
apiKey: process.env.TTS_API_KEY,
requireVerification: process.env.TTS_REQUIRE_WEBHOOK_VERIFICATION !== 'false',
};
}
/**
* Verify HMAC signature for TTS webhooks
*
* TTS can sign webhook payloads with HMAC-SHA256.
* The signature is provided in the X-Webhook-Signature header.
*
* @param payload - Raw request body
* @param signature - Signature from header
* @param secret - Webhook secret
* @returns True if signature is valid
*/
function verifyHmacSignature(
payload: string | Buffer,
signature: string,
secret: string
): boolean {
try {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (signatureBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
} catch (error) {
logger.error('Error verifying HMAC signature', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return false;
}
}
/**
* Verify API key for TTS webhooks
*
* TTS can include an API key in the X-Downlink-Apikey header.
* This is the same key used for sending downlinks.
*
* @param providedKey - API key from header
* @param expectedKey - Expected API key from config
* @returns True if API key matches
*/
function verifyApiKey(providedKey: string, expectedKey: string): boolean {
try {
const providedBuffer = Buffer.from(providedKey);
const expectedBuffer = Buffer.from(expectedKey);
if (providedBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
} catch (error) {
return false;
}
}
/**
* Middleware to verify TTS webhook authenticity
*
* Verification methods (checked in order):
* 1. X-Downlink-Apikey header (matches TTS_API_KEY)
* 2. X-Webhook-Signature header (HMAC-SHA256 with TTS_WEBHOOK_SECRET)
*
* If TTS_WEBHOOK_SECRET is not set and TTS_REQUIRE_WEBHOOK_VERIFICATION is 'false',
* webhooks will be accepted without verification (not recommended for production).
*/
export function verifyTtsWebhook(
req: TtsWebhookRequest,
res: Response,
next: NextFunction
): void {
const config = getTtsWebhookConfig();
req.ttsVerified = false;
// Check X-Downlink-Apikey header first
const apiKeyHeader = req.headers['x-downlink-apikey'] as string | undefined;
if (apiKeyHeader && config.apiKey) {
if (verifyApiKey(apiKeyHeader, config.apiKey)) {
logger.debug('TTS webhook verified via API key');
req.ttsVerified = true;
req.ttsApiKey = apiKeyHeader;
next();
return;
} else {
logger.warn('TTS webhook API key verification failed');
}
}
// Check X-Webhook-Signature header
const signatureHeader = req.headers['x-webhook-signature'] as string | undefined;
if (signatureHeader && config.webhookSecret) {
// We need the raw body for signature verification
// This requires the raw body to be preserved in the request
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody;
if (rawBody) {
if (verifyHmacSignature(rawBody, signatureHeader, config.webhookSecret)) {
logger.debug('TTS webhook verified via signature');
req.ttsVerified = true;
next();
return;
} else {
logger.warn('TTS webhook signature verification failed');
}
} else {
// Try with JSON stringified body as fallback
const bodyString = JSON.stringify(req.body);
if (verifyHmacSignature(bodyString, signatureHeader, config.webhookSecret)) {
logger.debug('TTS webhook verified via signature (fallback)');
req.ttsVerified = true;
next();
return;
} else {
logger.warn('TTS webhook signature verification failed (fallback)');
}
}
}
// If no verification method succeeded
if (config.requireVerification) {
// Check if any verification method is configured
if (!config.webhookSecret && !config.apiKey) {
logger.warn('TTS webhook verification is required but no secrets are configured');
res.status(500).json({
success: false,
error: 'Webhook verification not configured',
message: 'Server is not configured for webhook verification',
});
return;
}
logger.warn('TTS webhook verification failed', {
hasApiKeyHeader: !!apiKeyHeader,
hasSignatureHeader: !!signatureHeader,
hasApiKeyConfig: !!config.apiKey,
hasSecretConfig: !!config.webhookSecret,
});
res.status(401).json({
success: false,
error: 'Unauthorized',
message: 'Invalid or missing webhook authentication',
});
return;
}
// Verification not required, proceed without verification
logger.debug('TTS webhook proceeding without verification (verification not required)');
next();
}
/**
* Middleware to capture raw body for signature verification
*
* This middleware should be used BEFORE the JSON body parser
* for routes that need signature verification.
*
* Usage:
* app.use('/api/webhooks/tts', captureRawBody, express.json(), ttsRoutes);
*/
export function captureRawBody(
req: TtsWebhookRequest,
_res: Response,
next: NextFunction
): void {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
(req as unknown as { rawBody: Buffer }).rawBody = rawBody;
// Parse JSON body manually
if (rawBody.length > 0) {
try {
req.body = JSON.parse(rawBody.toString('utf-8'));
} catch (error) {
logger.warn('Failed to parse webhook body as JSON');
}
}
next();
});
req.on('error', (error) => {
logger.error('Error reading webhook body', {
error: error instanceof Error ? error.message : 'Unknown error',
});
next(error);
});
}
/**
* Middleware to extract and validate common TTS webhook fields
*
* Extracts device identifiers and adds them to the request for easier access.
*/
export function extractTtsPayloadInfo(
req: TtsWebhookRequest,
_res: Response,
next: NextFunction
): void {
try {
const body = req.body;
if (body?.end_device_ids) {
// Add convenience properties to request
(req as unknown as { devEui?: string }).devEui = body.end_device_ids.dev_eui;
(req as unknown as { ttsDeviceId?: string }).ttsDeviceId = body.end_device_ids.device_id;
(req as unknown as { applicationId?: string }).applicationId =
body.end_device_ids.application_ids?.application_id;
}
next();
} catch (error) {
logger.warn('Failed to extract TTS payload info', {
error: error instanceof Error ? error.message : 'Unknown error',
});
next();
}
}
/**
* Logging middleware for TTS webhooks
*
* Logs incoming webhook requests with relevant details.
*/
export function logTtsWebhook(
req: TtsWebhookRequest,
_res: Response,
next: NextFunction
): void {
const body = req.body;
logger.info('Incoming TTS webhook', {
path: req.path,
method: req.method,
devEui: body?.end_device_ids?.dev_eui,
deviceId: body?.end_device_ids?.device_id,
applicationId: body?.end_device_ids?.application_ids?.application_id,
contentType: req.headers['content-type'],
hasApiKey: !!req.headers['x-downlink-apikey'],
hasSignature: !!req.headers['x-webhook-signature'],
userAgent: req.headers['user-agent'],
ip: req.ip || req.socket.remoteAddress,
});
next();
}
export default {
verifyTtsWebhook,
captureRawBody,
extractTtsPayloadInfo,
logTtsWebhook,
};

View File

View File

View File

@@ -0,0 +1,41 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.middleware';
import { validateLogin, validateRefresh } from '../validators/auth.validator';
import * as authController from '../controllers/auth.controller';
const router = Router();
/**
* POST /auth/login
* Public endpoint - authenticate user and receive tokens
* Body: { email: string, password: string }
* Response: { message, accessToken, refreshToken }
*/
router.post('/login', validateLogin, authController.login);
/**
* POST /auth/refresh
* Public endpoint - refresh access token using refresh token
* Body: { refreshToken: string }
* Response: { message, accessToken }
*/
router.post('/refresh', validateRefresh, authController.refresh);
/**
* POST /auth/logout
* Protected endpoint - invalidate refresh token
* Headers: Authorization: Bearer <accessToken>
* Body: { refreshToken: string }
* Response: { message }
*/
router.post('/logout', authenticateToken, validateRefresh, authController.logout);
/**
* GET /auth/me
* Protected endpoint - get authenticated user profile
* Headers: Authorization: Bearer <accessToken>
* Response: { user: UserProfile }
*/
router.get('/me', authenticateToken, authController.getMe);
export default router;

View File

@@ -0,0 +1,40 @@
import { Router } from 'express';
import {
uploadMeters,
downloadMeterTemplate,
uploadReadings,
downloadReadingTemplate,
upload,
} from '../controllers/bulk-upload.controller';
import { authenticateToken } from '../middleware/auth.middleware';
const router = Router();
// All routes require authentication
router.use(authenticateToken);
/**
* POST /api/bulk-upload/meters
* Upload Excel file with meters data
*/
router.post('/meters', upload.single('file'), uploadMeters);
/**
* GET /api/bulk-upload/meters/template
* Download Excel template for meters
*/
router.get('/meters/template', downloadMeterTemplate);
/**
* POST /api/bulk-upload/readings
* Upload Excel file with readings data
*/
router.post('/readings', upload.single('file'), uploadReadings);
/**
* GET /api/bulk-upload/readings/template
* Download Excel template for readings
*/
router.get('/readings/template', downloadReadingTemplate);
export default router;

View File

@@ -0,0 +1,59 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.middleware';
import {
validateCreateConcentrator,
validateUpdateConcentrator,
} from '../validators/concentrator.validator';
import * as concentratorController from '../controllers/concentrator.controller';
const router = Router();
/**
* GET /concentrators
* Get all concentrators with optional filters and pagination
* Query params: project_id, status, page, limit, sortBy, sortOrder
* Protected endpoint - requires authentication
*/
router.get('/', authenticateToken, concentratorController.getAll);
/**
* GET /concentrators/:id
* Get a single concentrator by ID with gateway count
* Protected endpoint - requires authentication
*/
router.get('/:id', authenticateToken, concentratorController.getById);
/**
* POST /concentrators
* Create a new concentrator
* Body: { serial_number, name?, project_id, location?, status?, ip_address?, firmware_version? }
* Protected endpoint - requires authentication
*/
router.post(
'/',
authenticateToken,
validateCreateConcentrator,
concentratorController.create
);
/**
* PUT /concentrators/:id
* Update an existing concentrator
* Body: { serial_number?, name?, project_id?, location?, status?, ip_address?, firmware_version? }
* Protected endpoint - requires authentication
*/
router.put(
'/:id',
authenticateToken,
validateUpdateConcentrator,
concentratorController.update
);
/**
* DELETE /concentrators/:id
* Delete a concentrator (fails if gateways are associated)
* Protected endpoint - requires authentication
*/
router.delete('/:id', authenticateToken, concentratorController.remove);
export default router;

View File

@@ -0,0 +1,66 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.middleware';
import {
validateCreateDevice,
validateUpdateDevice,
} from '../validators/device.validator';
import * as deviceController from '../controllers/device.controller';
const router = Router();
/**
* GET /devices
* Get all devices with optional filters and pagination
* Query params: project_id, gateway_id, status, device_type, page, limit, sortBy, sortOrder
* Protected endpoint - requires authentication
*/
router.get('/', authenticateToken, deviceController.getAll);
/**
* GET /devices/dev-eui/:devEui
* Get a device by DevEUI
* Protected endpoint - requires authentication
*/
router.get('/dev-eui/:devEui', authenticateToken, deviceController.getByDevEui);
/**
* GET /devices/:id
* Get a single device by ID with meter info
* Protected endpoint - requires authentication
*/
router.get('/:id', authenticateToken, deviceController.getById);
/**
* POST /devices
* Create a new device
* Body: { dev_eui, name?, device_type?, project_id, gateway_id?, status?, tts_device_id?, app_key?, join_eui? }
* Protected endpoint - requires authentication
*/
router.post(
'/',
authenticateToken,
validateCreateDevice,
deviceController.create
);
/**
* PUT /devices/:id
* Update an existing device
* Body: { dev_eui?, name?, device_type?, project_id?, gateway_id?, status?, tts_device_id?, app_key?, join_eui? }
* Protected endpoint - requires authentication
*/
router.put(
'/:id',
authenticateToken,
validateUpdateDevice,
deviceController.update
);
/**
* DELETE /devices/:id
* Delete a device (sets meter's device_id to null if associated)
* Protected endpoint - requires authentication
*/
router.delete('/:id', authenticateToken, deviceController.remove);
export default router;

View File

@@ -0,0 +1,66 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.middleware';
import {
validateCreateGateway,
validateUpdateGateway,
} from '../validators/gateway.validator';
import * as gatewayController from '../controllers/gateway.controller';
const router = Router();
/**
* GET /gateways
* Get all gateways with optional filters and pagination
* Query params: project_id, concentrator_id, status, page, limit, sortBy, sortOrder
* Protected endpoint - requires authentication
*/
router.get('/', authenticateToken, gatewayController.getAll);
/**
* GET /gateways/:id
* Get a single gateway by ID with device count
* Protected endpoint - requires authentication
*/
router.get('/:id', authenticateToken, gatewayController.getById);
/**
* GET /gateways/:id/devices
* Get all devices for a specific gateway
* Protected endpoint - requires authentication
*/
router.get('/:id/devices', authenticateToken, gatewayController.getDevices);
/**
* POST /gateways
* Create a new gateway
* Body: { gateway_id, name?, project_id, concentrator_id?, location?, status?, tts_gateway_id? }
* Protected endpoint - requires authentication
*/
router.post(
'/',
authenticateToken,
validateCreateGateway,
gatewayController.create
);
/**
* PUT /gateways/:id
* Update an existing gateway
* Body: { gateway_id?, name?, project_id?, concentrator_id?, location?, status?, tts_gateway_id? }
* Protected endpoint - requires authentication
*/
router.put(
'/:id',
authenticateToken,
validateUpdateGateway,
gatewayController.update
);
/**
* DELETE /gateways/:id
* Delete a gateway (fails if devices are associated)
* Protected endpoint - requires authentication
*/
router.delete('/:id', authenticateToken, gatewayController.remove);
export default router;

View File

@@ -0,0 +1,133 @@
import { Router } from 'express';
// Import all route files
import authRoutes from './auth.routes';
import projectRoutes from './project.routes';
import meterRoutes from './meter.routes';
import concentratorRoutes from './concentrator.routes';
import gatewayRoutes from './gateway.routes';
import deviceRoutes from './device.routes';
import userRoutes from './user.routes';
import roleRoutes from './role.routes';
import ttsRoutes from './tts.routes';
import readingRoutes from './reading.routes';
import bulkUploadRoutes from './bulk-upload.routes';
// Create main router
const router = Router();
/**
* Mount all routes with proper prefixes
*
* Authentication routes:
* - POST /auth/login - Authenticate user
* - POST /auth/refresh - Refresh access token
* - POST /auth/logout - Logout user
* - GET /auth/me - Get current user profile
*/
router.use('/auth', authRoutes);
/**
* Project routes:
* - GET /projects - List all projects
* - GET /projects/:id - Get project by ID
* - GET /projects/:id/stats - Get project statistics
* - POST /projects - Create project
* - PUT /projects/:id - Update project
* - DELETE /projects/:id - Delete project
*/
router.use('/projects', projectRoutes);
/**
* Meter routes:
* - GET /meters - List all meters
* - GET /meters/:id - Get meter by ID
* - GET /meters/:id/readings - Get meter readings
* - POST /meters - Create meter
* - PUT /meters/:id - Update meter
* - DELETE /meters/:id - Delete meter
*/
router.use('/meters', meterRoutes);
/**
* Concentrator routes:
* - GET /concentrators - List all concentrators
* - GET /concentrators/:id - Get concentrator by ID
* - POST /concentrators - Create concentrator
* - PUT /concentrators/:id - Update concentrator
* - DELETE /concentrators/:id - Delete concentrator
*/
router.use('/concentrators', concentratorRoutes);
/**
* Gateway routes:
* - GET /gateways - List all gateways
* - GET /gateways/:id - Get gateway by ID
* - GET /gateways/:id/devices - Get gateway devices
* - POST /gateways - Create gateway
* - PUT /gateways/:id - Update gateway
* - DELETE /gateways/:id - Delete gateway
*/
router.use('/gateways', gatewayRoutes);
/**
* Device routes:
* - GET /devices - List all devices
* - GET /devices/:id - Get device by ID
* - GET /devices/dev-eui/:devEui - Get device by DevEUI
* - POST /devices - Create device
* - PUT /devices/:id - Update device
* - DELETE /devices/:id - Delete device
*/
router.use('/devices', deviceRoutes);
/**
* User routes:
* - GET /users - List all users (admin only)
* - GET /users/:id - Get user by ID (admin or self)
* - POST /users - Create user (admin only)
* - PUT /users/:id - Update user (admin or self)
* - DELETE /users/:id - Deactivate user (admin only)
* - PUT /users/:id/password - Change password (self only)
*/
router.use('/users', userRoutes);
/**
* Role routes:
* - GET /roles - List all roles
* - GET /roles/:id - Get role by ID with user count
* - POST /roles - Create role (admin only)
* - PUT /roles/:id - Update role (admin only)
* - DELETE /roles/:id - Delete role (admin only)
*/
router.use('/roles', roleRoutes);
/**
* TTS (The Things Stack) webhook routes:
* - GET /webhooks/tts/health - Health check
* - POST /webhooks/tts/uplink - Handle uplink messages
* - POST /webhooks/tts/join - Handle join events
* - POST /webhooks/tts/downlink/ack - Handle downlink acknowledgments
*
* Note: These routes use webhook secret verification instead of JWT auth
*/
router.use('/webhooks/tts', ttsRoutes);
/**
* Reading routes:
* - GET /readings - List all readings with filtering
* - GET /readings/summary - Get consumption summary
* - GET /readings/:id - Get reading by ID
* - POST /readings - Create reading
* - DELETE /readings/:id - Delete reading (admin only)
*/
router.use('/readings', readingRoutes);
/**
* Bulk upload routes:
* - POST /bulk-upload/meters - Upload Excel file with meters data
* - GET /bulk-upload/meters/template - Download Excel template for meters
*/
router.use('/bulk-upload', bulkUploadRoutes);
export default router;

View File

@@ -0,0 +1,61 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import { validateCreateMeter, validateUpdateMeter } from '../validators/meter.validator';
import * as meterController from '../controllers/meter.controller';
const router = Router();
/**
* GET /meters
* Public endpoint - list all meters with pagination and filtering
* Query params: page, pageSize, project_id, status, area_name, meter_type, search
* Response: { success: true, data: Meter[], pagination: {...} }
*/
router.get('/', meterController.getAll);
/**
* GET /meters/:id
* Public endpoint - get a single meter by ID with device info
* Response: { success: true, data: MeterWithDevice }
*/
router.get('/:id', meterController.getById);
/**
* GET /meters/:id/readings
* Public endpoint - get meter readings history
* Query params: start_date, end_date
* Response: { success: true, data: MeterReading[] }
*/
router.get('/:id/readings', meterController.getReadings);
/**
* POST /meters
* Protected endpoint - create a new meter
* Headers: Authorization: Bearer <accessToken>
* Body: { serial_number: string, name?: string, project_id: string, device_id?: string,
* area_name?: string, location?: string, meter_type?: string, status?: string,
* installation_date?: string }
* Response: { success: true, data: Meter }
*/
router.post('/', authenticateToken, validateCreateMeter, meterController.create);
/**
* PUT /meters/:id
* Protected endpoint - update an existing meter
* Headers: Authorization: Bearer <accessToken>
* Body: { serial_number?: string, name?: string, project_id?: string, device_id?: string,
* area_name?: string, location?: string, meter_type?: string, status?: string,
* installation_date?: string }
* Response: { success: true, data: Meter }
*/
router.put('/:id', authenticateToken, validateUpdateMeter, meterController.update);
/**
* DELETE /meters/:id
* Protected endpoint - delete a meter (requires admin role)
* Headers: Authorization: Bearer <accessToken>
* Response: { success: true, data: { message: string } }
*/
router.delete('/:id', authenticateToken, requireRole('admin', 'ADMIN'), meterController.deleteMeter);
export default router;

View File

@@ -0,0 +1,56 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import { validateCreateProject, validateUpdateProject } from '../validators/project.validator';
import * as projectController from '../controllers/project.controller';
const router = Router();
/**
* GET /projects
* Public endpoint - list all projects with pagination
* Query params: page, pageSize, status, area_name, search
* Response: { success: true, data: Project[], pagination: {...} }
*/
router.get('/', projectController.getAll);
/**
* GET /projects/:id
* Public endpoint - get a single project by ID
* Response: { success: true, data: Project }
*/
router.get('/:id', projectController.getById);
/**
* GET /projects/:id/stats
* Public endpoint - get project statistics
* Response: { success: true, data: ProjectStats }
*/
router.get('/:id/stats', projectController.getStats);
/**
* POST /projects
* Protected endpoint - create a new project
* Headers: Authorization: Bearer <accessToken>
* Body: { name: string, description?: string, area_name?: string, location?: string, status?: string }
* Response: { success: true, data: Project }
*/
router.post('/', authenticateToken, validateCreateProject, projectController.create);
/**
* PUT /projects/:id
* Protected endpoint - update an existing project
* Headers: Authorization: Bearer <accessToken>
* Body: { name?: string, description?: string, area_name?: string, location?: string, status?: string }
* Response: { success: true, data: Project }
*/
router.put('/:id', authenticateToken, validateUpdateProject, projectController.update);
/**
* DELETE /projects/:id
* Protected endpoint - delete a project (requires admin role)
* Headers: Authorization: Bearer <accessToken>
* Response: { success: true, data: { message: string } }
*/
router.delete('/:id', authenticateToken, requireRole('admin', 'ADMIN'), projectController.deleteProject);
export default router;

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import * as readingController from '../controllers/reading.controller';
const router = Router();
/**
* GET /readings/summary
* Public endpoint - get consumption summary statistics
* Query params: project_id
* Response: { success: true, data: { totalReadings, totalMeters, avgReading, lastReadingDate } }
*/
router.get('/summary', readingController.getSummary);
/**
* GET /readings
* Public endpoint - list all readings with pagination and filtering
* Query params: page, pageSize, meter_id, project_id, area_name, start_date, end_date, reading_type
* Response: { success: true, data: Reading[], pagination: {...} }
*/
router.get('/', readingController.getAll);
/**
* GET /readings/:id
* Public endpoint - get a single reading by ID
* Response: { success: true, data: ReadingWithMeter }
*/
router.get('/:id', readingController.getById);
/**
* POST /readings
* Protected endpoint - create a new reading
* Headers: Authorization: Bearer <accessToken>
* Body: { meter_id: string, device_id?: string, reading_value: number, reading_type?: string,
* battery_level?: number, signal_strength?: number, raw_payload?: string, received_at?: string }
* Response: { success: true, data: Reading }
*/
router.post('/', authenticateToken, readingController.create);
/**
* DELETE /readings/:id
* Protected endpoint - delete a reading (requires admin role)
* Headers: Authorization: Bearer <accessToken>
* Response: { success: true, data: { message: string } }
*/
router.delete('/:id', authenticateToken, requireRole('admin', 'ADMIN'), readingController.deleteReading);
export default router;

View File

@@ -0,0 +1,50 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import { validateCreateRole, validateUpdateRole } from '../validators/role.validator';
import * as roleController from '../controllers/role.controller';
const router = Router();
/**
* All routes require authentication
*/
router.use(authenticateToken);
/**
* GET /roles
* List all roles (all authenticated users)
* Response: { success, message, data: Role[] }
*/
router.get('/', roleController.getAllRoles);
/**
* GET /roles/:id
* Get a single role by ID with user count (all authenticated users)
* Response: { success, message, data: RoleWithUserCount }
*/
router.get('/:id', roleController.getRoleById);
/**
* POST /roles
* Create a new role (admin only)
* Body: { name: 'ADMIN'|'OPERATOR'|'VIEWER', description?, permissions? }
* Response: { success, message, data: Role }
*/
router.post('/', requireRole('ADMIN'), validateCreateRole, roleController.createRole);
/**
* PUT /roles/:id
* Update a role (admin only)
* Body: { name?, description?, permissions? }
* Response: { success, message, data: Role }
*/
router.put('/:id', requireRole('ADMIN'), validateUpdateRole, roleController.updateRole);
/**
* DELETE /roles/:id
* Delete a role (admin only, only if no users assigned)
* Response: { success, message }
*/
router.delete('/:id', requireRole('ADMIN'), roleController.deleteRole);
export default router;

View File

@@ -0,0 +1,148 @@
import { Router } from 'express';
import * as ttsController from '../controllers/tts.controller';
import {
verifyTtsWebhook,
logTtsWebhook,
extractTtsPayloadInfo,
} from '../middleware/ttsWebhook.middleware';
import {
validateUplink,
validateJoin,
validateDownlinkAck,
} from '../validators/tts.validator';
const router = Router();
/**
* TTS Webhook Routes
*
* These routes handle incoming webhooks from The Things Stack (TTS).
* They do NOT require standard authentication (Bearer token).
* Instead, they use webhook secret verification via the X-Downlink-Apikey
* or X-Webhook-Signature headers.
*
* Mount these routes at: /api/webhooks/tts
*
* Environment variables for configuration:
* - TTS_WEBHOOK_SECRET: Secret for HMAC signature verification
* - TTS_API_KEY: API key for X-Downlink-Apikey verification
* - TTS_REQUIRE_WEBHOOK_VERIFICATION: Set to 'false' to disable verification (not recommended)
*/
/**
* Middleware chain for all TTS webhooks:
* 1. Log incoming request
* 2. Verify webhook authenticity
* 3. Extract device info from payload
*/
const commonMiddleware = [
logTtsWebhook,
verifyTtsWebhook,
extractTtsPayloadInfo,
];
/**
* GET /api/webhooks/tts/health
* Health check endpoint
*
* Can be used to verify the webhook endpoint is reachable.
* No authentication required.
*/
router.get('/health', ttsController.healthCheck);
/**
* POST /api/webhooks/tts/uplink
* Handle uplink messages from devices
*
* Payload structure:
* {
* "end_device_ids": { "device_id": "...", "dev_eui": "...", ... },
* "received_at": "2024-01-01T00:00:00Z",
* "uplink_message": {
* "f_port": 1,
* "frm_payload": "base64...",
* "decoded_payload": { ... },
* "rx_metadata": [{ "gateway_ids": {...}, "rssi": -100, "snr": 5.5 }]
* }
* }
*
* Response: 200 OK on success (even if processing fails, to prevent TTS retries)
* Response: 500 Internal Server Error for unexpected errors (TTS will retry)
*/
router.post(
'/uplink',
...commonMiddleware,
validateUplink,
ttsController.handleUplink
);
/**
* POST /api/webhooks/tts/join
* Handle join accept events
*
* Received when a device successfully joins the LoRaWAN network.
*
* Payload structure:
* {
* "end_device_ids": { "device_id": "...", "dev_eui": "...", ... },
* "received_at": "2024-01-01T00:00:00Z",
* "join_accept": { "session_key_id": "...", "received_at": "..." }
* }
*/
router.post(
'/join',
...commonMiddleware,
validateJoin,
ttsController.handleJoin
);
/**
* POST /api/webhooks/tts/downlink/ack
* Handle downlink acknowledgments and status updates
*
* Received when:
* - A confirmed downlink is acknowledged by the device
* - A downlink is sent to a gateway
* - A downlink fails to be delivered
* - A downlink is queued
*
* The payload will contain one of:
* - downlink_ack: Device acknowledged the downlink
* - downlink_sent: Downlink was sent to gateway
* - downlink_failed: Downlink failed to be delivered
* - downlink_queued: Downlink was queued for later delivery
*/
router.post(
'/downlink/ack',
...commonMiddleware,
validateDownlinkAck,
ttsController.handleDownlinkAck
);
/**
* Alias routes for compatibility with different TTS webhook configurations
*/
// Alternative path for downlink events
router.post(
'/downlink/sent',
...commonMiddleware,
validateDownlinkAck,
ttsController.handleDownlinkAck
);
router.post(
'/downlink/failed',
...commonMiddleware,
validateDownlinkAck,
ttsController.handleDownlinkAck
);
router.post(
'/downlink/queued',
...commonMiddleware,
validateDownlinkAck,
ttsController.handleDownlinkAck
);
export default router;

View File

@@ -0,0 +1,63 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import {
validateCreateUser,
validateUpdateUser,
validateChangePassword,
} from '../validators/user.validator';
import * as userController from '../controllers/user.controller';
const router = Router();
/**
* All routes require authentication
*/
router.use(authenticateToken);
/**
* GET /users
* List all users (admin only)
* Query params: role_id, is_active, search, page, limit, sortBy, sortOrder
* Response: { success, message, data: User[], pagination }
*/
router.get('/', requireRole('ADMIN'), userController.getAllUsers);
/**
* GET /users/:id
* Get a single user by ID (admin or self)
* Response: { success, message, data: User }
*/
router.get('/:id', userController.getUserById);
/**
* POST /users
* Create a new user (admin only)
* Body: { email, password, first_name, last_name, role_id, is_active? }
* Response: { success, message, data: User }
*/
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser);
/**
* PUT /users/:id
* Update a user (admin can update all, self can update limited fields)
* Body: { email?, first_name?, last_name?, role_id?, is_active? }
* Response: { success, message, data: User }
*/
router.put('/:id', validateUpdateUser, userController.updateUser);
/**
* DELETE /users/:id
* Deactivate a user (soft delete, admin only)
* Response: { success, message }
*/
router.delete('/:id', requireRole('ADMIN'), userController.deleteUser);
/**
* PUT /users/:id/password
* Change user password (self only)
* Body: { current_password, new_password }
* Response: { success, message }
*/
router.put('/:id/password', validateChangePassword, userController.changePassword);
export default router;

View File

View File

@@ -0,0 +1,252 @@
import { query } from '../config/database';
import {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
} from '../utils/jwt';
import { comparePassword } from '../utils/password';
import crypto from 'crypto';
/**
* Hash a token for storage
*/
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* Authentication service response types
*/
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
role: string;
avatarUrl?: string | null;
createdAt: Date;
}
export interface LoginResult extends AuthTokens {
user: UserProfile;
}
/**
* Authenticate user with email and password
* Generates access and refresh tokens on successful login
* Stores hashed refresh token in database
* @param email - User email
* @param password - User password
* @returns Access and refresh tokens with user info
*/
export async function login(
email: string,
password: string
): Promise<LoginResult> {
// Find user by email with role name
const userResult = await query<{
id: string;
email: string;
name: string;
password_hash: string;
avatar_url: string | null;
role_name: string;
created_at: Date;
}>(
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.created_at
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
LIMIT 1`,
[email]
);
const user = userResult.rows[0];
if (!user) {
throw new Error('Invalid email or password');
}
// Verify password
const isValidPassword = await comparePassword(password, user.password_hash);
if (!isValidPassword) {
throw new Error('Invalid email or password');
}
// Generate tokens
const accessToken = generateAccessToken({
id: user.id,
email: user.email,
role: user.role_name,
});
const refreshToken = generateRefreshToken({
id: user.id,
});
// Hash and store refresh token
const hashedRefreshToken = hashToken(refreshToken);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, hashedRefreshToken, expiresAt]
);
// Update last login
await query(
`UPDATE users SET last_login = NOW() WHERE id = $1`,
[user.id]
);
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role_name,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
},
};
}
/**
* Refresh access token using a valid refresh token
* Verifies the refresh token exists in database and is not expired
* @param refreshToken - The refresh token
* @returns New access token
*/
export async function refresh(refreshToken: string): Promise<{ accessToken: string }> {
// Verify JWT signature
const decoded = verifyRefreshToken(refreshToken);
if (!decoded) {
throw new Error('Invalid refresh token');
}
// Hash token to check against database
const hashedToken = hashToken(refreshToken);
// Find token in database
const tokenResult = await query<{
id: string;
expires_at: Date;
}>(
`SELECT id, expires_at FROM refresh_tokens
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
LIMIT 1`,
[hashedToken, decoded.id]
);
const storedToken = tokenResult.rows[0];
if (!storedToken) {
throw new Error('Refresh token not found or revoked');
}
// Check if token is expired
if (new Date() > storedToken.expires_at) {
// Clean up expired token
await query(
`DELETE FROM refresh_tokens WHERE id = $1`,
[storedToken.id]
);
throw new Error('Refresh token expired');
}
// Get user data for new access token
const userResult = await query<{
id: string;
email: string;
role_name: string;
}>(
`SELECT u.id, u.email, r.name as role_name
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true
LIMIT 1`,
[decoded.id]
);
const user = userResult.rows[0];
if (!user) {
throw new Error('User not found');
}
// Generate new access token
const accessToken = generateAccessToken({
id: user.id,
email: user.email,
role: user.role_name,
});
return { accessToken };
}
/**
* Logout user by revoking the specified refresh token
* @param userId - The user ID
* @param refreshToken - The refresh token to revoke
*/
export async function logout(
userId: string,
refreshToken: string
): Promise<void> {
const hashedToken = hashToken(refreshToken);
// Revoke the specific refresh token
await query(
`UPDATE refresh_tokens SET revoked_at = NOW()
WHERE token_hash = $1 AND user_id = $2`,
[hashedToken, userId]
);
}
/**
* Get authenticated user's profile
* @param userId - The user ID
* @returns User profile data
*/
export async function getMe(userId: string): Promise<UserProfile> {
const userResult = await query<{
id: string;
email: string;
name: string;
avatar_url: string | null;
role_name: string;
created_at: Date;
}>(
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.created_at
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true
LIMIT 1`,
[userId]
);
const user = userResult.rows[0];
if (!user) {
throw new Error('User not found');
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role_name,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
};
}

View File

@@ -0,0 +1,660 @@
import * as XLSX from 'xlsx';
import { query } from '../config/database';
/**
* Result of a bulk upload operation
*/
export interface BulkUploadResult {
success: boolean;
totalRows: number;
inserted: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
}
/**
* Expected columns in the Excel file for meters
*/
interface MeterRow {
serial_number: string;
meter_id?: string;
name: string;
concentrator_serial: string; // We'll look up the concentrator by serial
location?: string;
type?: string;
status?: string;
installation_date?: string;
}
/**
* Parse Excel file buffer and return rows
*/
function parseExcelBuffer(buffer: Buffer): Record<string, unknown>[] {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// Convert to JSON with header row
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, {
defval: null,
raw: false,
});
return rows;
}
/**
* Normalize column names (handle variations)
*/
function normalizeColumnName(name: string): string {
const normalized = name
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[áàäâ]/g, 'a')
.replace(/[éèëê]/g, 'e')
.replace(/[íìïî]/g, 'i')
.replace(/[óòöô]/g, 'o')
.replace(/[úùüû]/g, 'u')
.replace(/ñ/g, 'n');
// Map common variations
const mappings: Record<string, string> = {
// Serial number
'serial': 'serial_number',
'numero_de_serie': 'serial_number',
'serial_number': 'serial_number',
'device_s/n': 'serial_number',
'device_sn': 'serial_number',
's/n': 'serial_number',
'sn': 'serial_number',
// Meter ID
'meter_id': 'meter_id',
'meterid': 'meter_id',
'id_medidor': 'meter_id',
// Name
'nombre': 'name',
'name': 'name',
'device_name': 'name',
'meter_name': 'name',
'nombre_medidor': 'name',
// Concentrator
'concentrador': 'concentrator_serial',
'concentrator': 'concentrator_serial',
'concentrator_serial': 'concentrator_serial',
'serial_concentrador': 'concentrator_serial',
'gateway': 'concentrator_serial',
'gateway_serial': 'concentrator_serial',
// Location
'ubicacion': 'location',
'location': 'location',
'direccion': 'location',
'address': 'location',
// Type
'tipo': 'type',
'type': 'type',
'device_type': 'type',
'tipo_dispositivo': 'type',
'protocol': 'type',
'protocolo': 'type',
// Status
'estado': 'status',
'status': 'status',
'device_status': 'status',
'estado_dispositivo': 'status',
// Installation date
'fecha_instalacion': 'installation_date',
'installation_date': 'installation_date',
'fecha_de_instalacion': 'installation_date',
'installed_time': 'installation_date',
'installed_date': 'installation_date',
};
return mappings[normalized] || normalized;
}
/**
* Normalize row data with column name mapping
*/
function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
const normalizedKey = normalizeColumnName(key);
normalized[normalizedKey] = value;
}
return normalized;
}
/**
* Validate a meter row
*/
function validateMeterRow(row: Record<string, unknown>, rowIndex: number): { valid: boolean; error?: string } {
if (!row.serial_number || String(row.serial_number).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: serial_number es requerido` };
}
if (!row.name || String(row.name).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: name es requerido` };
}
if (!row.concentrator_serial || String(row.concentrator_serial).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: concentrator_serial es requerido` };
}
return { valid: true };
}
/**
* Bulk upload meters from Excel buffer
*/
export async function bulkUploadMeters(buffer: Buffer): Promise<BulkUploadResult> {
const result: BulkUploadResult = {
success: true,
totalRows: 0,
inserted: 0,
errors: [],
};
try {
// Parse Excel file
const rawRows = parseExcelBuffer(buffer);
result.totalRows = rawRows.length;
if (rawRows.length === 0) {
result.success = false;
result.errors.push({ row: 0, error: 'El archivo está vacío o no tiene datos válidos' });
return result;
}
// Normalize column names
const rows = rawRows.map(row => normalizeRow(row));
// Get all concentrators for lookup
const concentratorsResult = await query<{ id: string; serial_number: string }>(
'SELECT id, serial_number FROM concentrators'
);
const concentratorMap = new Map(
concentratorsResult.rows.map(c => [c.serial_number.toLowerCase(), c.id])
);
// Process each row
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2; // Excel row number (1-indexed + header row)
// Validate row
const validation = validateMeterRow(row, rowIndex);
if (!validation.valid) {
result.errors.push({ row: rowIndex, error: validation.error!, data: row });
continue;
}
// Look up concentrator
const concentratorSerial = String(row.concentrator_serial).trim().toLowerCase();
const concentratorId = concentratorMap.get(concentratorSerial);
if (!concentratorId) {
result.errors.push({
row: rowIndex,
error: `Concentrador con serial "${row.concentrator_serial}" no encontrado`,
data: row,
});
continue;
}
// Prepare meter data
// Validate installation_date is actually a valid date
let installationDate: string | undefined = undefined;
if (row.installation_date) {
const dateStr = String(row.installation_date).trim();
// Check if it looks like a date (contains numbers and possibly dashes/slashes)
if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(dateStr) || /^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}/.test(dateStr)) {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
installationDate = parsed.toISOString().split('T')[0];
}
}
}
const meterData: MeterRow = {
serial_number: String(row.serial_number).trim(),
meter_id: row.meter_id ? String(row.meter_id).trim() : undefined,
name: String(row.name).trim(),
concentrator_serial: String(row.concentrator_serial).trim(),
location: row.location ? String(row.location).trim() : undefined,
type: row.type ? String(row.type).trim().toUpperCase() : 'LORA',
status: row.status ? String(row.status).trim().toUpperCase() : 'ACTIVE',
installation_date: installationDate,
};
// Validate type
const validTypes = ['LORA', 'LORAWAN', 'GRANDES'];
if (!validTypes.includes(meterData.type!)) {
meterData.type = 'LORA';
}
// Validate and normalize status
const statusMappings: Record<string, string> = {
'ACTIVE': 'ACTIVE',
'INACTIVE': 'INACTIVE',
'MAINTENANCE': 'MAINTENANCE',
'FAULTY': 'FAULTY',
'REPLACED': 'REPLACED',
'INSTALLED': 'ACTIVE',
'NEW_LORA': 'ACTIVE',
'NEW': 'ACTIVE',
'ENABLED': 'ACTIVE',
'DISABLED': 'INACTIVE',
'OFFLINE': 'INACTIVE',
'ONLINE': 'ACTIVE',
};
const normalizedStatus = meterData.status?.toUpperCase().replace(/\s+/g, '_') || 'ACTIVE';
meterData.status = statusMappings[normalizedStatus] || 'ACTIVE';
// Insert meter
try {
await query(
`INSERT INTO meters (serial_number, meter_id, name, concentrator_id, location, type, status, installation_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
meterData.serial_number,
meterData.meter_id || null,
meterData.name,
concentratorId,
meterData.location || null,
meterData.type,
meterData.status,
meterData.installation_date || null,
]
);
result.inserted++;
} catch (err) {
const error = err as Error & { code?: string; detail?: string };
let errorMessage = error.message;
if (error.code === '23505') {
errorMessage = `Serial "${meterData.serial_number}" ya existe en la base de datos`;
}
result.errors.push({
row: rowIndex,
error: errorMessage,
data: row,
});
}
}
result.success = result.errors.length === 0;
} catch (err) {
const error = err as Error;
result.success = false;
result.errors.push({ row: 0, error: `Error procesando archivo: ${error.message}` });
}
return result;
}
/**
* Generate Excel template for meters
*/
export function generateMeterTemplate(): Buffer {
const templateData = [
{
serial_number: 'EJEMPLO-001',
meter_id: 'MID-001',
name: 'Medidor Ejemplo 1',
concentrator_serial: 'CONC-001',
location: 'Ubicación ejemplo',
type: 'LORA',
status: 'ACTIVE',
installation_date: '2024-01-15',
},
{
serial_number: 'EJEMPLO-002',
meter_id: 'MID-002',
name: 'Medidor Ejemplo 2',
concentrator_serial: 'CONC-001',
location: 'Otra ubicación',
type: 'LORAWAN',
status: 'ACTIVE',
installation_date: '2024-01-16',
},
];
const worksheet = XLSX.utils.json_to_sheet(templateData);
// Set column widths
worksheet['!cols'] = [
{ wch: 15 }, // serial_number
{ wch: 12 }, // meter_id
{ wch: 25 }, // name
{ wch: 20 }, // concentrator_serial
{ wch: 25 }, // location
{ wch: 10 }, // type
{ wch: 12 }, // status
{ wch: 15 }, // installation_date
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Medidores');
// Add instructions sheet
const instructionsData = [
{ Campo: 'serial_number', Descripcion: 'Número de serie del medidor (REQUERIDO, único)', Ejemplo: 'MED-2024-001' },
{ Campo: 'meter_id', Descripcion: 'ID del medidor (opcional)', Ejemplo: 'ID-001' },
{ Campo: 'name', Descripcion: 'Nombre del medidor (REQUERIDO)', Ejemplo: 'Medidor Casa 1' },
{ Campo: 'concentrator_serial', Descripcion: 'Serial del concentrador (REQUERIDO)', Ejemplo: 'CONC-001' },
{ Campo: 'location', Descripcion: 'Ubicación (opcional)', Ejemplo: 'Calle Principal #123' },
{ Campo: 'type', Descripcion: 'Tipo: LORA, LORAWAN, GRANDES (opcional, default: LORA)', Ejemplo: 'LORA' },
{ Campo: 'status', Descripcion: 'Estado: ACTIVE, INACTIVE, MAINTENANCE, FAULTY, REPLACED (opcional, default: ACTIVE)', Ejemplo: 'ACTIVE' },
{ Campo: 'installation_date', Descripcion: 'Fecha de instalación YYYY-MM-DD (opcional)', Ejemplo: '2024-01-15' },
];
const instructionsSheet = XLSX.utils.json_to_sheet(instructionsData);
instructionsSheet['!cols'] = [
{ wch: 20 },
{ wch: 60 },
{ wch: 20 },
];
XLSX.utils.book_append_sheet(workbook, instructionsSheet, 'Instrucciones');
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
}
/**
* Expected columns in the Excel file for readings
*/
interface ReadingRow {
meter_serial: string;
reading_value: number;
reading_type?: string;
received_at?: string;
battery_level?: number;
signal_strength?: number;
}
/**
* Normalize column name for readings
*/
function normalizeReadingColumnName(name: string): string {
const normalized = name
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[áàäâ]/g, 'a')
.replace(/[éèëê]/g, 'e')
.replace(/[íìïî]/g, 'i')
.replace(/[óòöô]/g, 'o')
.replace(/[úùüû]/g, 'u')
.replace(/ñ/g, 'n');
const mappings: Record<string, string> = {
// Meter serial
'serial': 'meter_serial',
'serial_number': 'meter_serial',
'meter_serial': 'meter_serial',
'numero_de_serie': 'meter_serial',
'serial_medidor': 'meter_serial',
'medidor': 'meter_serial',
// Reading value
'valor': 'reading_value',
'value': 'reading_value',
'reading_value': 'reading_value',
'lectura': 'reading_value',
'consumo': 'reading_value',
// Reading type
'tipo': 'reading_type',
'type': 'reading_type',
'reading_type': 'reading_type',
'tipo_lectura': 'reading_type',
// Received at
'fecha': 'received_at',
'date': 'received_at',
'received_at': 'received_at',
'fecha_lectura': 'received_at',
'fecha_hora': 'received_at',
// Battery
'bateria': 'battery_level',
'battery': 'battery_level',
'battery_level': 'battery_level',
'nivel_bateria': 'battery_level',
// Signal
'senal': 'signal_strength',
'signal': 'signal_strength',
'signal_strength': 'signal_strength',
'intensidad_senal': 'signal_strength',
};
return mappings[normalized] || normalized;
}
/**
* Normalize row for readings
*/
function normalizeReadingRow(row: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
const normalizedKey = normalizeReadingColumnName(key);
normalized[normalizedKey] = value;
}
return normalized;
}
/**
* Validate a reading row
*/
function validateReadingRow(row: Record<string, unknown>, rowIndex: number): { valid: boolean; error?: string } {
if (!row.meter_serial || String(row.meter_serial).trim() === '') {
return { valid: false, error: `Fila ${rowIndex}: meter_serial es requerido` };
}
if (row.reading_value === null || row.reading_value === undefined || row.reading_value === '') {
return { valid: false, error: `Fila ${rowIndex}: reading_value es requerido` };
}
const value = parseFloat(String(row.reading_value));
if (isNaN(value)) {
return { valid: false, error: `Fila ${rowIndex}: reading_value debe ser un número` };
}
return { valid: true };
}
/**
* Bulk upload readings from Excel buffer
*/
export async function bulkUploadReadings(buffer: Buffer): Promise<BulkUploadResult> {
const result: BulkUploadResult = {
success: true,
totalRows: 0,
inserted: 0,
errors: [],
};
try {
// Parse Excel file
const rawRows = parseExcelBuffer(buffer);
result.totalRows = rawRows.length;
if (rawRows.length === 0) {
result.success = false;
result.errors.push({ row: 0, error: 'El archivo está vacío o no tiene datos válidos' });
return result;
}
// Normalize column names
const rows = rawRows.map(row => normalizeReadingRow(row));
// Get all meters for lookup by serial number
const metersResult = await query<{ id: string; serial_number: string }>(
'SELECT id, serial_number FROM meters'
);
const meterMap = new Map(
metersResult.rows.map(m => [m.serial_number.toLowerCase(), m.id])
);
// Process each row
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2; // Excel row number (1-indexed + header row)
// Validate row
const validation = validateReadingRow(row, rowIndex);
if (!validation.valid) {
result.errors.push({ row: rowIndex, error: validation.error!, data: row });
continue;
}
// Look up meter
const meterSerial = String(row.meter_serial).trim().toLowerCase();
const meterId = meterMap.get(meterSerial);
if (!meterId) {
result.errors.push({
row: rowIndex,
error: `Medidor con serial "${row.meter_serial}" no encontrado`,
data: row,
});
continue;
}
// Prepare reading data
const readingValue = parseFloat(String(row.reading_value));
const readingType = row.reading_type ? String(row.reading_type).trim().toUpperCase() : 'MANUAL';
const receivedAt = row.received_at ? String(row.received_at).trim() : null;
// Parse battery level - handle NaN and invalid values
let batteryLevel: number | null = null;
if (row.battery_level !== undefined && row.battery_level !== null && row.battery_level !== '') {
const parsed = parseFloat(String(row.battery_level));
if (!isNaN(parsed) && isFinite(parsed)) {
batteryLevel = parsed;
}
}
// Parse signal strength - handle NaN and invalid values
let signalStrength: number | null = null;
if (row.signal_strength !== undefined && row.signal_strength !== null && row.signal_strength !== '') {
const parsed = parseFloat(String(row.signal_strength));
if (!isNaN(parsed) && isFinite(parsed)) {
signalStrength = parsed;
}
}
// Validate reading type
const validTypes = ['AUTOMATIC', 'MANUAL', 'SCHEDULED'];
const finalReadingType = validTypes.includes(readingType) ? readingType : 'MANUAL';
// Insert reading
try {
await query(
`INSERT INTO meter_readings (meter_id, reading_value, reading_type, battery_level, signal_strength, received_at)
VALUES ($1, $2, $3, $4, $5, COALESCE($6::timestamp, NOW()))`,
[
meterId,
readingValue,
finalReadingType,
batteryLevel,
signalStrength,
receivedAt,
]
);
// Update meter's last reading
await query(
`UPDATE meters
SET last_reading_value = $1, last_reading_at = COALESCE($2::timestamp, NOW()), updated_at = NOW()
WHERE id = $3`,
[readingValue, receivedAt, meterId]
);
result.inserted++;
} catch (err) {
const error = err as Error & { code?: string; detail?: string };
result.errors.push({
row: rowIndex,
error: error.message,
data: row,
});
}
}
result.success = result.errors.length === 0;
} catch (err) {
const error = err as Error;
result.success = false;
result.errors.push({ row: 0, error: `Error procesando archivo: ${error.message}` });
}
return result;
}
/**
* Generate Excel template for readings
*/
export function generateReadingTemplate(): Buffer {
const templateData = [
{
meter_serial: '24151158',
reading_value: 123.45,
reading_type: 'MANUAL',
received_at: '2024-01-15 10:30:00',
battery_level: 85,
signal_strength: -70,
},
{
meter_serial: '24151159',
reading_value: 456.78,
reading_type: 'MANUAL',
received_at: '2024-01-15 10:35:00',
battery_level: 90,
signal_strength: -65,
},
];
const worksheet = XLSX.utils.json_to_sheet(templateData);
// Set column widths
worksheet['!cols'] = [
{ wch: 15 }, // meter_serial
{ wch: 15 }, // reading_value
{ wch: 12 }, // reading_type
{ wch: 20 }, // received_at
{ wch: 15 }, // battery_level
{ wch: 15 }, // signal_strength
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Lecturas');
// Add instructions sheet
const instructionsData = [
{ Campo: 'meter_serial', Descripcion: 'Número de serie del medidor (REQUERIDO)', Ejemplo: '24151158' },
{ Campo: 'reading_value', Descripcion: 'Valor de la lectura en m³ (REQUERIDO)', Ejemplo: '123.45' },
{ Campo: 'reading_type', Descripcion: 'Tipo: AUTOMATIC, MANUAL, SCHEDULED (opcional, default: MANUAL)', Ejemplo: 'MANUAL' },
{ Campo: 'received_at', Descripcion: 'Fecha y hora de la lectura YYYY-MM-DD HH:MM:SS (opcional, default: ahora)', Ejemplo: '2024-01-15 10:30:00' },
{ Campo: 'battery_level', Descripcion: 'Nivel de batería en % (opcional)', Ejemplo: '85' },
{ Campo: 'signal_strength', Descripcion: 'Intensidad de señal en dBm (opcional)', Ejemplo: '-70' },
];
const instructionsSheet = XLSX.utils.json_to_sheet(instructionsData);
instructionsSheet['!cols'] = [
{ wch: 20 },
{ wch: 60 },
{ wch: 25 },
];
XLSX.utils.book_append_sheet(workbook, instructionsSheet, 'Instrucciones');
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
}

View File

@@ -0,0 +1,296 @@
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;
}

View File

@@ -0,0 +1,341 @@
import { pool } from '../config/database';
import { CreateDeviceInput, UpdateDeviceInput } from '../validators/device.validator';
/**
* Device entity interface
*/
export interface Device {
id: string;
dev_eui: string;
name: string | null;
device_type: string | null;
project_id: string;
gateway_id: string | null;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
tts_device_id: string | null;
tts_status: string | null;
tts_last_seen: Date | null;
app_key: string | null;
join_eui: string | null;
created_at: Date;
updated_at: Date;
}
/**
* Device with meter info
*/
export interface DeviceWithMeter extends Device {
meter_id: string | null;
meter_number: string | null;
}
/**
* Filter options for devices
*/
export interface DeviceFilters {
project_id?: string;
gateway_id?: string;
status?: string;
device_type?: string;
}
/**
* 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 devices with optional filters and pagination
* @param filters - Optional filter criteria
* @param pagination - Optional pagination options
* @returns Paginated list of devices
*/
export async function getAll(
filters?: DeviceFilters,
pagination?: PaginationOptions
): Promise<PaginatedResult<Device>> {
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?.gateway_id) {
conditions.push(`gateway_id = $${paramIndex}`);
params.push(filters.gateway_id);
paramIndex++;
}
if (filters?.status) {
conditions.push(`status = $${paramIndex}`);
params.push(filters.status);
paramIndex++;
}
if (filters?.device_type) {
conditions.push(`device_type = $${paramIndex}`);
params.push(filters.device_type);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Validate sort column to prevent SQL injection
const allowedSortColumns = ['id', 'dev_eui', 'name', 'device_type', '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 devices ${whereClause}`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0].count, 10);
// Get data
const dataQuery = `
SELECT * FROM devices
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(limit, offset);
const dataResult = await pool.query<Device>(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 device by ID with meter info
* @param id - Device UUID
* @returns Device with meter info or null
*/
export async function getById(id: string): Promise<DeviceWithMeter | null> {
const query = `
SELECT
d.*,
m.id as meter_id,
m.meter_number
FROM devices d
LEFT JOIN meters m ON m.device_id = d.id
WHERE d.id = $1
`;
const result = await pool.query<DeviceWithMeter>(query, [id]);
return result.rows[0] || null;
}
/**
* Get a device by DevEUI
* @param devEui - Device DevEUI
* @returns Device or null
*/
export async function getByDevEui(devEui: string): Promise<Device | null> {
const query = `
SELECT * FROM devices
WHERE dev_eui = $1
`;
const result = await pool.query<Device>(query, [devEui.toUpperCase()]);
return result.rows[0] || null;
}
/**
* Create a new device
* @param data - Device creation data
* @returns Created device
*/
export async function create(data: CreateDeviceInput): Promise<Device> {
const query = `
INSERT INTO devices (dev_eui, name, device_type, project_id, gateway_id, status, tts_device_id, app_key, join_eui)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const params = [
data.dev_eui.toUpperCase(),
data.name || null,
data.device_type || null,
data.project_id,
data.gateway_id || null,
data.status || 'unknown',
data.tts_device_id || null,
data.app_key || null,
data.join_eui || null,
];
const result = await pool.query<Device>(query, params);
return result.rows[0];
}
/**
* Update an existing device
* @param id - Device UUID
* @param data - Update data
* @returns Updated device or null if not found
*/
export async function update(id: string, data: UpdateDeviceInput): Promise<Device | null> {
// Build SET clause dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.dev_eui !== undefined) {
updates.push(`dev_eui = $${paramIndex}`);
params.push(data.dev_eui.toUpperCase());
paramIndex++;
}
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.device_type !== undefined) {
updates.push(`device_type = $${paramIndex}`);
params.push(data.device_type);
paramIndex++;
}
if (data.project_id !== undefined) {
updates.push(`project_id = $${paramIndex}`);
params.push(data.project_id);
paramIndex++;
}
if (data.gateway_id !== undefined) {
updates.push(`gateway_id = $${paramIndex}`);
params.push(data.gateway_id);
paramIndex++;
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex}`);
params.push(data.status);
paramIndex++;
}
if (data.tts_device_id !== undefined) {
updates.push(`tts_device_id = $${paramIndex}`);
params.push(data.tts_device_id);
paramIndex++;
}
if (data.app_key !== undefined) {
updates.push(`app_key = $${paramIndex}`);
params.push(data.app_key);
paramIndex++;
}
if (data.join_eui !== undefined) {
updates.push(`join_eui = $${paramIndex}`);
params.push(data.join_eui);
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 devices
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await pool.query<Device>(query, params);
return result.rows[0] || null;
}
/**
* Delete a device
* Sets meter's device_id to null if a meter is associated
* @param id - Device UUID
* @returns True if deleted
*/
export async function remove(id: string): Promise<boolean> {
// Set meter's device_id to null if associated
await pool.query(
'UPDATE meters SET device_id = NULL WHERE device_id = $1',
[id]
);
const result = await pool.query(
'DELETE FROM devices WHERE id = $1 RETURNING id',
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}
/**
* Update TTS status fields
* @param id - Device UUID
* @param status - TTS status
* @param lastSeen - Last seen timestamp
* @returns Updated device or null
*/
export async function updateTtsStatus(
id: string,
status: string,
lastSeen: Date
): Promise<Device | null> {
const query = `
UPDATE devices
SET tts_status = $1, tts_last_seen = $2, updated_at = NOW()
WHERE id = $3
RETURNING *
`;
const result = await pool.query<Device>(query, [status, lastSeen, id]);
return result.rows[0] || null;
}

View File

@@ -0,0 +1,324 @@
import { pool } from '../config/database';
import { CreateGatewayInput, UpdateGatewayInput } from '../validators/gateway.validator';
/**
* Gateway entity interface
*/
export interface Gateway {
id: string;
gateway_id: string;
name: string | null;
project_id: string;
concentrator_id: string | null;
location: string | null;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
tts_gateway_id: string | null;
tts_status: string | null;
tts_last_seen: Date | null;
created_at: Date;
updated_at: Date;
}
/**
* Gateway with device count
*/
export interface GatewayWithCount extends Gateway {
device_count: number;
}
/**
* Filter options for gateways
*/
export interface GatewayFilters {
project_id?: string;
concentrator_id?: string;
status?: string;
}
/**
* 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 gateways with optional filters and pagination
* @param filters - Optional filter criteria
* @param pagination - Optional pagination options
* @returns Paginated list of gateways
*/
export async function getAll(
filters?: GatewayFilters,
pagination?: PaginationOptions
): Promise<PaginatedResult<Gateway>> {
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?.concentrator_id) {
conditions.push(`concentrator_id = $${paramIndex}`);
params.push(filters.concentrator_id);
paramIndex++;
}
if (filters?.status) {
conditions.push(`status = $${paramIndex}`);
params.push(filters.status);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Validate sort column to prevent SQL injection
const allowedSortColumns = ['id', 'gateway_id', '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 gateways ${whereClause}`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0].count, 10);
// Get data
const dataQuery = `
SELECT * FROM gateways
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(limit, offset);
const dataResult = await pool.query<Gateway>(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 gateway by ID with device count
* @param id - Gateway UUID
* @returns Gateway with device count or null
*/
export async function getById(id: string): Promise<GatewayWithCount | null> {
const query = `
SELECT
g.*,
COALESCE(COUNT(d.id), 0)::int as device_count
FROM gateways g
LEFT JOIN devices d ON d.gateway_id = g.id
WHERE g.id = $1
GROUP BY g.id
`;
const result = await pool.query<GatewayWithCount>(query, [id]);
return result.rows[0] || null;
}
/**
* Get devices for a specific gateway
* @param gatewayId - Gateway UUID
* @returns List of devices
*/
export async function getDevices(gatewayId: string): Promise<unknown[]> {
const query = `
SELECT * FROM devices
WHERE gateway_id = $1
ORDER BY created_at DESC
`;
const result = await pool.query(query, [gatewayId]);
return result.rows;
}
/**
* Create a new gateway
* @param data - Gateway creation data
* @returns Created gateway
*/
export async function create(data: CreateGatewayInput): Promise<Gateway> {
const query = `
INSERT INTO gateways (gateway_id, name, project_id, concentrator_id, location, status, tts_gateway_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
const params = [
data.gateway_id,
data.name || null,
data.project_id,
data.concentrator_id || null,
data.location || null,
data.status || 'unknown',
data.tts_gateway_id || null,
];
const result = await pool.query<Gateway>(query, params);
return result.rows[0];
}
/**
* Update an existing gateway
* @param id - Gateway UUID
* @param data - Update data
* @returns Updated gateway or null if not found
*/
export async function update(id: string, data: UpdateGatewayInput): Promise<Gateway | null> {
// Build SET clause dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.gateway_id !== undefined) {
updates.push(`gateway_id = $${paramIndex}`);
params.push(data.gateway_id);
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.concentrator_id !== undefined) {
updates.push(`concentrator_id = $${paramIndex}`);
params.push(data.concentrator_id);
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++;
}
if (data.tts_gateway_id !== undefined) {
updates.push(`tts_gateway_id = $${paramIndex}`);
params.push(data.tts_gateway_id);
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 gateways
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await pool.query<Gateway>(query, params);
return result.rows[0] || null;
}
/**
* Delete a gateway
* Checks for dependent devices before deletion
* @param id - Gateway UUID
* @returns True if deleted, throws error if has dependencies
*/
export async function remove(id: string): Promise<boolean> {
// Check for dependent devices
const deviceCheck = await pool.query(
'SELECT COUNT(*) FROM devices WHERE gateway_id = $1',
[id]
);
const deviceCount = parseInt(deviceCheck.rows[0].count, 10);
if (deviceCount > 0) {
throw new Error(`Cannot delete gateway: ${deviceCount} device(s) are associated with it`);
}
const result = await pool.query(
'DELETE FROM gateways WHERE id = $1 RETURNING id',
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}
/**
* Update TTS status fields
* @param id - Gateway UUID
* @param status - TTS status
* @param lastSeen - Last seen timestamp
* @returns Updated gateway or null
*/
export async function updateTtsStatus(
id: string,
status: string,
lastSeen: Date
): Promise<Gateway | null> {
const query = `
UPDATE gateways
SET tts_status = $1, tts_last_seen = $2, updated_at = NOW()
WHERE id = $3
RETURNING *
`;
const result = await pool.query<Gateway>(query, [status, lastSeen, id]);
return result.rows[0] || null;
}

View File

@@ -0,0 +1,317 @@
import { query } from '../config/database';
/**
* Meter interface matching database schema
* Meters are linked to concentrators (not directly to projects)
*/
export interface Meter {
id: string;
serial_number: string;
meter_id: string | null;
name: string;
concentrator_id: string;
location: string | null;
type: string;
status: string;
last_reading_value: number | null;
last_reading_at: Date | null;
installation_date: Date | null;
created_at: Date;
updated_at: Date;
}
/**
* Meter with concentrator and project info
*/
export interface MeterWithDetails extends Meter {
concentrator_name?: string;
concentrator_serial?: string;
project_id?: string;
project_name?: string;
}
/**
* Pagination parameters
*/
export interface PaginationParams {
page: number;
pageSize: number;
}
/**
* Filter parameters for meters
*/
export interface MeterFilters {
concentrator_id?: string;
project_id?: string;
status?: string;
type?: string;
search?: string;
}
/**
* Paginated result interface
*/
export interface PaginatedResult<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
/**
* Input for creating a meter
*/
export interface CreateMeterInput {
serial_number: string;
meter_id?: string | null;
name: string;
concentrator_id: string;
location?: string;
type?: string;
status?: string;
installation_date?: string;
}
/**
* Input for updating a meter
*/
export interface UpdateMeterInput {
serial_number?: string;
meter_id?: string | null;
name?: string;
concentrator_id?: string;
location?: string;
type?: string;
status?: string;
installation_date?: string;
}
/**
* Get all meters with optional filtering and pagination
*/
export async function getAll(
filters?: MeterFilters,
pagination?: PaginationParams
): Promise<PaginatedResult<MeterWithDetails>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50;
const offset = (page - 1) * pageSize;
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters?.concentrator_id) {
conditions.push(`m.concentrator_id = $${paramIndex}`);
params.push(filters.concentrator_id);
paramIndex++;
}
if (filters?.project_id) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(filters.project_id);
paramIndex++;
}
if (filters?.status) {
conditions.push(`m.status = $${paramIndex}`);
params.push(filters.status);
paramIndex++;
}
if (filters?.type) {
conditions.push(`m.type = $${paramIndex}`);
params.push(filters.type);
paramIndex++;
}
if (filters?.search) {
conditions.push(`(m.serial_number ILIKE $${paramIndex} OR m.name ILIKE $${paramIndex})`);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Count query
const countQuery = `
SELECT COUNT(*) as total
FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id
${whereClause}
`;
const countResult = await query<{ total: string }>(countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Data query with joins
const dataQuery = `
SELECT
m.id, m.serial_number, m.meter_id, m.name, m.concentrator_id, m.location, m.type,
m.status, m.last_reading_value, m.last_reading_at, m.installation_date,
m.created_at, m.updated_at,
c.name as concentrator_name, c.serial_number as concentrator_serial,
c.project_id, p.name as project_name
FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id
${whereClause}
ORDER BY m.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(pageSize, offset);
const result = await query<MeterWithDetails>(dataQuery, params);
return {
data: result.rows,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
/**
* Get a single meter by ID with details
*/
export async function getById(id: string): Promise<MeterWithDetails | null> {
const result = await query<MeterWithDetails>(
`SELECT
m.id, m.serial_number, m.meter_id, m.name, m.concentrator_id, m.location, m.type,
m.status, m.last_reading_value, m.last_reading_at, m.installation_date,
m.created_at, m.updated_at,
c.name as concentrator_name, c.serial_number as concentrator_serial,
c.project_id, p.name as project_name
FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id
WHERE m.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Create a new meter
*/
export async function create(data: CreateMeterInput): Promise<Meter> {
const result = await query<Meter>(
`INSERT INTO meters (serial_number, meter_id, name, concentrator_id, location, type, status, installation_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.serial_number,
data.meter_id || null,
data.name,
data.concentrator_id,
data.location || null,
data.type || 'LORA',
data.status || 'ACTIVE',
data.installation_date || null,
]
);
return result.rows[0];
}
/**
* Update an existing meter
*/
export async function update(id: string, data: UpdateMeterInput): Promise<Meter | null> {
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.meter_id !== undefined) {
updates.push(`meter_id = $${paramIndex}`);
params.push(data.meter_id);
paramIndex++;
}
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.concentrator_id !== undefined) {
updates.push(`concentrator_id = $${paramIndex}`);
params.push(data.concentrator_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.installation_date !== undefined) {
updates.push(`installation_date = $${paramIndex}`);
params.push(data.installation_date);
paramIndex++;
}
updates.push(`updated_at = NOW()`);
if (updates.length === 1) {
return getById(id) as Promise<Meter | null>;
}
params.push(id);
const result = await query<Meter>(
`UPDATE meters SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
params
);
return result.rows[0] || null;
}
/**
* Delete a meter
*/
export async function deleteMeter(id: string): Promise<boolean> {
const result = await query('DELETE FROM meters WHERE id = $1', [id]);
return (result.rowCount || 0) > 0;
}
/**
* Update last reading value
*/
export async function updateLastReading(id: string, value: number): Promise<Meter | null> {
const result = await query<Meter>(
`UPDATE meters
SET last_reading_value = $1, last_reading_at = NOW(), updated_at = NOW()
WHERE id = $2
RETURNING *`,
[value, id]
);
return result.rows[0] || null;
}

View 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),
};
}

View File

@@ -0,0 +1,288 @@
import { query } from '../config/database';
/**
* Meter reading interface matching database schema
*/
export interface MeterReading {
id: string;
meter_id: string;
reading_value: number;
reading_type: string;
battery_level: number | null;
signal_strength: number | null;
raw_payload: string | null;
received_at: Date;
created_at: Date;
}
/**
* Meter reading with meter and project info (through concentrators)
*/
export interface MeterReadingWithMeter extends MeterReading {
meter_serial_number: string;
meter_name: string;
meter_location: string | null;
concentrator_id: string;
concentrator_name: string;
project_id: string;
project_name: string;
}
/**
* Pagination parameters interface
*/
export interface PaginationParams {
page: number;
pageSize: number;
}
/**
* Filter parameters for readings
*/
export interface ReadingFilters {
meter_id?: string;
concentrator_id?: string;
project_id?: string;
start_date?: string;
end_date?: string;
reading_type?: string;
}
/**
* Paginated result interface
*/
export interface PaginatedResult<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
/**
* Reading input for creating new readings
*/
export interface CreateReadingInput {
meter_id: string;
reading_value: number;
reading_type?: string;
battery_level?: number;
signal_strength?: number;
raw_payload?: string;
received_at?: string;
}
/**
* Get all readings with optional filtering and pagination
* @param filters - Optional filters
* @param pagination - Optional pagination parameters
* @returns Paginated list of readings with meter info
*/
export async function getAll(
filters?: ReadingFilters,
pagination?: PaginationParams
): Promise<PaginatedResult<MeterReadingWithMeter>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50;
const offset = (page - 1) * pageSize;
// Build WHERE clause dynamically
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters?.meter_id) {
conditions.push(`mr.meter_id = $${paramIndex}`);
params.push(filters.meter_id);
paramIndex++;
}
if (filters?.concentrator_id) {
conditions.push(`m.concentrator_id = $${paramIndex}`);
params.push(filters.concentrator_id);
paramIndex++;
}
if (filters?.project_id) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(filters.project_id);
paramIndex++;
}
if (filters?.start_date) {
conditions.push(`mr.received_at >= $${paramIndex}`);
params.push(filters.start_date);
paramIndex++;
}
if (filters?.end_date) {
conditions.push(`mr.received_at <= $${paramIndex}`);
params.push(filters.end_date);
paramIndex++;
}
if (filters?.reading_type) {
conditions.push(`mr.reading_type = $${paramIndex}`);
params.push(filters.reading_type);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM meter_readings mr
JOIN meters m ON mr.meter_id = m.id
JOIN concentrators c ON m.concentrator_id = c.id
${whereClause}
`;
const countResult = await query<{ total: string }>(countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Get paginated data with meter, concentrator, and project info
const dataQuery = `
SELECT
mr.id, mr.meter_id, mr.reading_value, mr.reading_type,
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
m.concentrator_id, c.name as concentrator_name,
c.project_id, p.name as project_name
FROM meter_readings mr
JOIN meters m ON mr.meter_id = m.id
JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id
${whereClause}
ORDER BY mr.received_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(pageSize, offset);
const result = await query<MeterReadingWithMeter>(dataQuery, params);
return {
data: result.rows,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
/**
* Get a single reading by ID
* @param id - Reading UUID
* @returns Reading with meter info or null if not found
*/
export async function getById(id: string): Promise<MeterReadingWithMeter | null> {
const result = await query<MeterReadingWithMeter>(
`SELECT
mr.id, mr.meter_id, mr.reading_value, mr.reading_type,
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
m.concentrator_id, c.name as concentrator_name,
c.project_id, p.name as project_name
FROM meter_readings mr
JOIN meters m ON mr.meter_id = m.id
JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id
WHERE mr.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Create a new reading
* @param data - Reading data
* @returns Created reading
*/
export async function create(data: CreateReadingInput): Promise<MeterReading> {
const result = await query<MeterReading>(
`INSERT INTO meter_readings (meter_id, reading_value, reading_type,
battery_level, signal_strength, raw_payload, received_at)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()))
RETURNING id, meter_id, reading_value, reading_type,
battery_level, signal_strength, raw_payload, received_at, created_at`,
[
data.meter_id,
data.reading_value,
data.reading_type || 'AUTOMATIC',
data.battery_level || null,
data.signal_strength || null,
data.raw_payload || null,
data.received_at || null,
]
);
// Update meter's last reading
await query(
`UPDATE meters
SET last_reading_value = $1, last_reading_at = $2, updated_at = NOW()
WHERE id = $3`,
[data.reading_value, result.rows[0].received_at, data.meter_id]
);
return result.rows[0];
}
/**
* Delete a reading by ID
* @param id - Reading UUID
* @returns True if deleted
*/
export async function deleteReading(id: string): Promise<boolean> {
const result = await query('DELETE FROM meter_readings WHERE id = $1', [id]);
return (result.rowCount || 0) > 0;
}
/**
* Get consumption summary by project
* @param projectId - Optional project ID to filter
* @returns Summary statistics
*/
export async function getConsumptionSummary(projectId?: string): Promise<{
totalReadings: number;
totalMeters: number;
avgReading: number;
lastReadingDate: Date | null;
}> {
const params: unknown[] = [];
let whereClause = '';
if (projectId) {
whereClause = 'WHERE c.project_id = $1';
params.push(projectId);
}
const result = await query<{
total_readings: string;
total_meters: string;
avg_reading: string;
last_reading: Date | null;
}>(
`SELECT
COUNT(mr.id) as total_readings,
COUNT(DISTINCT mr.meter_id) as total_meters,
COALESCE(AVG(mr.reading_value), 0) as avg_reading,
MAX(mr.received_at) as last_reading
FROM meter_readings mr
JOIN meters m ON mr.meter_id = m.id
JOIN concentrators c ON m.concentrator_id = c.id
${whereClause}`,
params
);
const row = result.rows[0];
return {
totalReadings: parseInt(row?.total_readings || '0', 10),
totalMeters: parseInt(row?.total_meters || '0', 10),
avgReading: parseFloat(row?.avg_reading || '0'),
lastReadingDate: row?.last_reading || null,
};
}

View File

@@ -0,0 +1,226 @@
import { query } from '../config/database';
import { Role } from '../types';
/**
* Role with user count for extended details
*/
export interface RoleWithUserCount extends Role {
user_count: number;
}
/**
* Get all roles
* @returns List of all roles
*/
export async function getAll(): Promise<Role[]> {
const result = await query<Role>(
`
SELECT
id,
name,
description,
permissions,
created_at,
updated_at
FROM roles
ORDER BY id ASC
`
);
return result.rows;
}
/**
* Get a single role by ID with user count
* @param id - Role ID
* @returns Role with user count or null if not found
*/
export async function getById(id: number): Promise<RoleWithUserCount | null> {
const result = await query(
`
SELECT
r.id,
r.name,
r.description,
r.permissions,
r.created_at,
r.updated_at,
COUNT(u.id)::integer as user_count
FROM roles r
LEFT JOIN users u ON r.id = u.role_id
WHERE r.id = $1
GROUP BY r.id
`,
[id]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0] as RoleWithUserCount;
}
/**
* Get a role by name
* @param name - Role name
* @returns Role or null if not found
*/
export async function getByName(name: string): Promise<Role | null> {
const result = await query<Role>(
`
SELECT
id,
name,
description,
permissions,
created_at,
updated_at
FROM roles
WHERE name = $1
`,
[name]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
}
/**
* Create a new role (admin only)
* @param data - Role data
* @returns Created role
*/
export async function create(data: {
name: string;
description?: string | null;
permissions?: Record<string, unknown> | null;
}): Promise<Role> {
// Check if role name already exists
const existingRole = await getByName(data.name);
if (existingRole) {
throw new Error('Role name already exists');
}
const result = await query<Role>(
`
INSERT INTO roles (name, description, permissions)
VALUES ($1, $2, $3)
RETURNING id, name, description, permissions, created_at, updated_at
`,
[
data.name,
data.description || null,
data.permissions ? JSON.stringify(data.permissions) : null,
]
);
return result.rows[0];
}
/**
* Update a role
* @param id - Role ID
* @param data - Fields to update
* @returns Updated role or null if not found
*/
export async function update(
id: number,
data: {
name?: string;
description?: string | null;
permissions?: Record<string, unknown> | null;
}
): Promise<Role | null> {
// Check if role exists
const existingRole = await getById(id);
if (!existingRole) {
return null;
}
// If name is being changed, check it's not already in use
if (data.name && data.name !== existingRole.name) {
const nameRole = await getByName(data.name);
if (nameRole) {
throw new Error('Role name already exists');
}
}
// Build UPDATE query dynamically
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.permissions !== undefined) {
updates.push(`permissions = $${paramIndex}`);
params.push(data.permissions ? JSON.stringify(data.permissions) : null);
paramIndex++;
}
if (updates.length === 0) {
// No updates to make
return existingRole;
}
updates.push(`updated_at = NOW()`);
params.push(id);
const updateQuery = `
UPDATE roles
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, name, description, permissions, created_at, updated_at
`;
const result = await query<Role>(updateQuery, params);
return result.rows[0];
}
/**
* Delete a role (only if no users assigned)
* @param id - Role ID
* @returns True if deleted, false if role not found
* @throws Error if users are assigned to the role
*/
export async function deleteRole(id: number): Promise<boolean> {
// Check if role exists and get user count
const role = await getById(id);
if (!role) {
return false;
}
// Check if any users are assigned to this role
if (role.user_count > 0) {
throw new Error(
`Cannot delete role: ${role.user_count} user(s) are currently assigned to this role`
);
}
const result = await query(
`
DELETE FROM roles
WHERE id = $1
RETURNING id
`,
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}

View File

@@ -0,0 +1,43 @@
/**
* TTS (The Things Stack) Integration Services
*
* This module provides integration with The Things Stack for LoRaWAN device management.
*
* Services:
* - payloadDecoder: Decode raw LoRaWAN payloads into structured meter readings
* - ttsWebhook: Process incoming webhooks from TTS (uplinks, joins, downlinks)
* - ttsApi: Make outgoing API calls to TTS (register devices, send downlinks)
*/
// Payload Decoder Service
export {
decodePayload,
decodeWithFallback,
DeviceType,
type DecodedPayload,
} from './payloadDecoder.service';
// TTS Webhook Service
export {
processUplink,
processJoin,
processDownlinkAck,
type UplinkProcessingResult,
type JoinProcessingResult,
type DownlinkAckProcessingResult,
} from './ttsWebhook.service';
// TTS API Service
export {
isTtsEnabled,
registerDevice,
deleteDevice,
sendDownlink,
getDeviceStatus,
hexToBase64,
base64ToHex,
type TtsDeviceRegistration,
type TtsDownlinkPayload,
type TtsDeviceStatus,
type TtsApiResult,
} from './ttsApi.service';

View File

@@ -0,0 +1,491 @@
import logger from '../../utils/logger';
/**
* Decoded payload structure containing water meter readings
*/
export interface DecodedPayload {
/** Current meter reading value (e.g., cubic meters) */
readingValue: number | null;
/** Battery level percentage (0-100) */
batteryLevel: number | null;
/** Battery voltage in volts */
batteryVoltage: number | null;
/** Signal strength (RSSI) */
signalStrength: number | null;
/** Temperature in Celsius */
temperature: number | null;
/** Whether there's a leak detected */
leakDetected: boolean;
/** Whether there's a tamper alert */
tamperAlert: boolean;
/** Whether the valve is open (for meters with valves) */
valveOpen: boolean | null;
/** Flow rate in liters per hour */
flowRate: number | null;
/** Total consumption since last reset */
totalConsumption: number | null;
/** Device status code */
statusCode: number | null;
/** Any error codes from the device */
errorCodes: string[];
/** Additional device-specific data */
rawFields: Record<string, unknown>;
/** Indicates if decoding was successful */
decodingSuccess: boolean;
/** Error message if decoding failed */
decodingError: string | null;
}
/**
* Device type identifiers for different water meter types
*/
export enum DeviceType {
GENERIC = 'GENERIC',
WATER_METER_V1 = 'WATER_METER_V1',
WATER_METER_V2 = 'WATER_METER_V2',
ULTRASONIC_METER = 'ULTRASONIC_METER',
PULSE_COUNTER = 'PULSE_COUNTER',
LORAWAN_WATER = 'LORAWAN_WATER',
}
/**
* Create an empty decoded payload with default values
*/
function createEmptyPayload(): DecodedPayload {
return {
readingValue: null,
batteryLevel: null,
batteryVoltage: null,
signalStrength: null,
temperature: null,
leakDetected: false,
tamperAlert: false,
valveOpen: null,
flowRate: null,
totalConsumption: null,
statusCode: null,
errorCodes: [],
rawFields: {},
decodingSuccess: false,
decodingError: null,
};
}
/**
* Decode generic water meter payload
* Format: [4 bytes reading][2 bytes battery][1 byte status]
*/
function decodeGenericMeter(buffer: Buffer): DecodedPayload {
const payload = createEmptyPayload();
if (buffer.length < 7) {
payload.decodingError = 'Payload too short for generic meter format';
return payload;
}
try {
// Reading value: 4 bytes, big-endian, in liters (divide by 1000 for cubic meters)
payload.readingValue = buffer.readUInt32BE(0) / 1000;
// Battery: 2 bytes, big-endian, in millivolts
const batteryMv = buffer.readUInt16BE(4);
payload.batteryVoltage = batteryMv / 1000;
// Estimate battery percentage (assuming 3.6V max, 2.5V min)
payload.batteryLevel = Math.min(100, Math.max(0, ((batteryMv - 2500) / 1100) * 100));
// Status byte
const status = buffer.readUInt8(6);
payload.leakDetected = (status & 0x01) !== 0;
payload.tamperAlert = (status & 0x02) !== 0;
payload.valveOpen = (status & 0x04) !== 0 ? true : (status & 0x08) !== 0 ? false : null;
payload.statusCode = status;
payload.decodingSuccess = true;
} catch (error) {
payload.decodingError = `Failed to decode generic meter: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
return payload;
}
/**
* Decode Water Meter V1 payload
* Format: [4 bytes reading][1 byte battery%][2 bytes temp][1 byte status][4 bytes flow]
*/
function decodeWaterMeterV1(buffer: Buffer): DecodedPayload {
const payload = createEmptyPayload();
if (buffer.length < 12) {
payload.decodingError = 'Payload too short for Water Meter V1 format';
return payload;
}
try {
// Reading value: 4 bytes, big-endian, in liters
payload.readingValue = buffer.readUInt32BE(0) / 1000;
// Battery percentage: 1 byte (0-100)
payload.batteryLevel = buffer.readUInt8(4);
// Temperature: 2 bytes, big-endian, signed, in 0.1 degrees Celsius
payload.temperature = buffer.readInt16BE(5) / 10;
// Status byte
const status = buffer.readUInt8(7);
payload.leakDetected = (status & 0x01) !== 0;
payload.tamperAlert = (status & 0x02) !== 0;
payload.statusCode = status;
// Error codes
if (status & 0x80) payload.errorCodes.push('SENSOR_ERROR');
if (status & 0x40) payload.errorCodes.push('MEMORY_ERROR');
if (status & 0x20) payload.errorCodes.push('COMMUNICATION_ERROR');
// Flow rate: 4 bytes, big-endian, in milliliters per hour
payload.flowRate = buffer.readUInt32BE(8) / 1000;
payload.decodingSuccess = true;
} catch (error) {
payload.decodingError = `Failed to decode Water Meter V1: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
return payload;
}
/**
* Decode Water Meter V2 payload (extended format)
* Format: [4 bytes reading][2 bytes battery mV][2 bytes temp][1 byte status][4 bytes flow][4 bytes total]
*/
function decodeWaterMeterV2(buffer: Buffer): DecodedPayload {
const payload = createEmptyPayload();
if (buffer.length < 17) {
payload.decodingError = 'Payload too short for Water Meter V2 format';
return payload;
}
try {
// Reading value: 4 bytes, big-endian, in deciliters
payload.readingValue = buffer.readUInt32BE(0) / 10000;
// Battery voltage: 2 bytes, big-endian, in millivolts
const batteryMv = buffer.readUInt16BE(4);
payload.batteryVoltage = batteryMv / 1000;
payload.batteryLevel = Math.min(100, Math.max(0, ((batteryMv - 2500) / 1100) * 100));
// Temperature: 2 bytes, big-endian, signed, in 0.01 degrees Celsius
payload.temperature = buffer.readInt16BE(6) / 100;
// Status byte
const status = buffer.readUInt8(8);
payload.leakDetected = (status & 0x01) !== 0;
payload.tamperAlert = (status & 0x02) !== 0;
payload.valveOpen = (status & 0x04) !== 0;
payload.statusCode = status;
// Flow rate: 4 bytes, big-endian, in milliliters per hour
payload.flowRate = buffer.readUInt32BE(9) / 1000;
// Total consumption: 4 bytes, big-endian, in liters
payload.totalConsumption = buffer.readUInt32BE(13) / 1000;
payload.decodingSuccess = true;
} catch (error) {
payload.decodingError = `Failed to decode Water Meter V2: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
return payload;
}
/**
* Decode Ultrasonic Meter payload
* Format: [4 bytes reading][2 bytes battery][2 bytes signal][1 byte status][4 bytes flow]
*/
function decodeUltrasonicMeter(buffer: Buffer): DecodedPayload {
const payload = createEmptyPayload();
if (buffer.length < 13) {
payload.decodingError = 'Payload too short for Ultrasonic Meter format';
return payload;
}
try {
// Reading value: 4 bytes, big-endian, in cubic meters * 1000
payload.readingValue = buffer.readUInt32BE(0) / 1000;
// Battery: 2 bytes, big-endian, percentage * 100
payload.batteryLevel = buffer.readUInt16BE(4) / 100;
// Signal quality: 2 bytes, big-endian
payload.signalStrength = buffer.readInt16BE(6);
// Status byte
const status = buffer.readUInt8(8);
payload.leakDetected = (status & 0x01) !== 0;
payload.tamperAlert = (status & 0x02) !== 0;
payload.statusCode = status;
if (status & 0x10) payload.errorCodes.push('NO_FLOW_DETECTED');
if (status & 0x20) payload.errorCodes.push('REVERSE_FLOW');
if (status & 0x40) payload.errorCodes.push('AIR_IN_PIPE');
// Flow rate: 4 bytes, big-endian, in liters per hour
payload.flowRate = buffer.readUInt32BE(9) / 1000;
payload.decodingSuccess = true;
} catch (error) {
payload.decodingError = `Failed to decode Ultrasonic Meter: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
return payload;
}
/**
* Decode Pulse Counter payload
* Format: [4 bytes pulse count][2 bytes battery][1 byte status]
*/
function decodePulseCounter(buffer: Buffer): DecodedPayload {
const payload = createEmptyPayload();
if (buffer.length < 7) {
payload.decodingError = 'Payload too short for Pulse Counter format';
return payload;
}
try {
// Pulse count: 4 bytes, big-endian
// Assuming 1 pulse = 1 liter, convert to cubic meters
const pulseCount = buffer.readUInt32BE(0);
payload.readingValue = pulseCount / 1000;
payload.rawFields['pulseCount'] = pulseCount;
// Battery: 2 bytes, big-endian, in millivolts
const batteryMv = buffer.readUInt16BE(4);
payload.batteryVoltage = batteryMv / 1000;
payload.batteryLevel = Math.min(100, Math.max(0, ((batteryMv - 2500) / 1100) * 100));
// Status byte
const status = buffer.readUInt8(6);
payload.tamperAlert = (status & 0x01) !== 0;
payload.statusCode = status;
payload.decodingSuccess = true;
} catch (error) {
payload.decodingError = `Failed to decode Pulse Counter: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
return payload;
}
/**
* Decode standard LoRaWAN water meter payload
* Format varies but typically: [1 byte type][4 bytes reading][remaining data]
*/
function decodeLoRaWANWater(buffer: Buffer): DecodedPayload {
const payload = createEmptyPayload();
if (buffer.length < 5) {
payload.decodingError = 'Payload too short for LoRaWAN Water format';
return payload;
}
try {
// First byte indicates message type
const msgType = buffer.readUInt8(0);
payload.rawFields['messageType'] = msgType;
// Reading value: 4 bytes, little-endian (common in LoRaWAN), in liters
payload.readingValue = buffer.readUInt32LE(1) / 1000;
// Optional additional data based on message length
if (buffer.length >= 7) {
// Battery percentage: 1 byte
payload.batteryLevel = buffer.readUInt8(5);
if (buffer.length >= 8) {
// Status byte
const status = buffer.readUInt8(6);
payload.leakDetected = (status & 0x01) !== 0;
payload.tamperAlert = (status & 0x02) !== 0;
payload.statusCode = status;
}
}
payload.decodingSuccess = true;
} catch (error) {
payload.decodingError = `Failed to decode LoRaWAN Water: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
return payload;
}
/**
* Main function to decode device payloads
* Supports multiple device types with different payload formats
*
* @param rawPayload - Base64 encoded payload string from TTS
* @param deviceType - Device type identifier to select appropriate decoder
* @returns Decoded payload with meter readings and device status
*/
export function decodePayload(rawPayload: string, deviceType: string): DecodedPayload {
const payload = createEmptyPayload();
if (!rawPayload) {
payload.decodingError = 'Empty payload received';
logger.warn('Payload decoding failed: Empty payload');
return payload;
}
let buffer: Buffer;
try {
buffer = Buffer.from(rawPayload, 'base64');
payload.rawFields['rawHex'] = buffer.toString('hex');
payload.rawFields['rawLength'] = buffer.length;
} catch (error) {
payload.decodingError = 'Failed to decode base64 payload';
logger.error('Payload decoding failed: Invalid base64', { rawPayload });
return payload;
}
if (buffer.length === 0) {
payload.decodingError = 'Decoded payload is empty';
logger.warn('Payload decoding failed: Empty buffer after base64 decode');
return payload;
}
logger.debug('Decoding payload', {
deviceType,
payloadHex: buffer.toString('hex'),
payloadLength: buffer.length,
});
// Select decoder based on device type
const normalizedType = deviceType.toUpperCase().replace(/[-\s]/g, '_');
let decodedPayload: DecodedPayload;
switch (normalizedType) {
case DeviceType.WATER_METER_V1:
decodedPayload = decodeWaterMeterV1(buffer);
break;
case DeviceType.WATER_METER_V2:
decodedPayload = decodeWaterMeterV2(buffer);
break;
case DeviceType.ULTRASONIC_METER:
decodedPayload = decodeUltrasonicMeter(buffer);
break;
case DeviceType.PULSE_COUNTER:
decodedPayload = decodePulseCounter(buffer);
break;
case DeviceType.LORAWAN_WATER:
decodedPayload = decodeLoRaWANWater(buffer);
break;
case DeviceType.GENERIC:
default:
// Try generic decoder for unknown device types
decodedPayload = decodeGenericMeter(buffer);
break;
}
// Merge raw fields from initial parsing
decodedPayload.rawFields = { ...payload.rawFields, ...decodedPayload.rawFields };
if (decodedPayload.decodingSuccess) {
logger.debug('Payload decoded successfully', {
deviceType,
readingValue: decodedPayload.readingValue,
batteryLevel: decodedPayload.batteryLevel,
});
} else {
logger.warn('Payload decoding failed', {
deviceType,
error: decodedPayload.decodingError,
payloadHex: buffer.toString('hex'),
});
}
return decodedPayload;
}
/**
* Try to decode payload using TTS pre-decoded payload if available
* Falls back to raw payload decoding if TTS decoding is not available
*
* @param rawPayload - Base64 encoded payload string
* @param ttsDecodedPayload - Pre-decoded payload from TTS (if available)
* @param deviceType - Device type identifier
* @returns Decoded payload
*/
export function decodeWithFallback(
rawPayload: string,
ttsDecodedPayload: Record<string, unknown> | undefined,
deviceType: string
): DecodedPayload {
// If TTS has already decoded the payload, use that data
if (ttsDecodedPayload && Object.keys(ttsDecodedPayload).length > 0) {
logger.debug('Using TTS pre-decoded payload', { ttsDecodedPayload });
const payload = createEmptyPayload();
// Map common TTS decoded fields to our structure
if ('reading' in ttsDecodedPayload || 'value' in ttsDecodedPayload || 'volume' in ttsDecodedPayload) {
const reading = ttsDecodedPayload.reading ?? ttsDecodedPayload.value ?? ttsDecodedPayload.volume;
if (typeof reading === 'number') {
payload.readingValue = reading;
}
}
if ('battery' in ttsDecodedPayload || 'batteryLevel' in ttsDecodedPayload) {
const battery = ttsDecodedPayload.battery ?? ttsDecodedPayload.batteryLevel;
if (typeof battery === 'number') {
payload.batteryLevel = battery;
}
}
if ('temperature' in ttsDecodedPayload) {
if (typeof ttsDecodedPayload.temperature === 'number') {
payload.temperature = ttsDecodedPayload.temperature;
}
}
if ('leak' in ttsDecodedPayload || 'leakDetected' in ttsDecodedPayload) {
payload.leakDetected = Boolean(ttsDecodedPayload.leak ?? ttsDecodedPayload.leakDetected);
}
if ('tamper' in ttsDecodedPayload || 'tamperAlert' in ttsDecodedPayload) {
payload.tamperAlert = Boolean(ttsDecodedPayload.tamper ?? ttsDecodedPayload.tamperAlert);
}
if ('flow' in ttsDecodedPayload || 'flowRate' in ttsDecodedPayload) {
const flow = ttsDecodedPayload.flow ?? ttsDecodedPayload.flowRate;
if (typeof flow === 'number') {
payload.flowRate = flow;
}
}
payload.rawFields = { ...ttsDecodedPayload };
payload.decodingSuccess = payload.readingValue !== null;
if (!payload.decodingSuccess) {
// Fall back to raw payload decoding
logger.debug('TTS decoded payload missing reading value, falling back to raw decoding');
return decodePayload(rawPayload, deviceType);
}
return payload;
}
// Fall back to raw payload decoding
return decodePayload(rawPayload, deviceType);
}
export default {
decodePayload,
decodeWithFallback,
DeviceType,
};

View File

@@ -0,0 +1,518 @@
import logger from '../../utils/logger';
/**
* TTS API configuration
*/
interface TtsApiConfig {
apiUrl: string;
apiKey: string;
applicationId: string;
enabled: boolean;
}
/**
* Device registration payload for TTS
*/
export interface TtsDeviceRegistration {
devEui: string;
joinEui: string;
deviceId: string;
name: string;
description?: string;
appKey: string;
nwkKey?: string;
lorawanVersion?: string;
lorawanPhyVersion?: string;
frequencyPlanId?: string;
supportsClassC?: boolean;
supportsJoin?: boolean;
}
/**
* Downlink message payload
*/
export interface TtsDownlinkPayload {
fPort: number;
frmPayload: string; // Base64 encoded
confirmed?: boolean;
priority?: 'LOWEST' | 'LOW' | 'BELOW_NORMAL' | 'NORMAL' | 'ABOVE_NORMAL' | 'HIGH' | 'HIGHEST';
classBC?: {
absoluteTime?: string;
};
}
/**
* Device status from TTS API
*/
export interface TtsDeviceStatus {
devEui: string;
deviceId: string;
name: string;
description?: string;
createdAt?: string;
updatedAt?: string;
lastSeenAt?: string;
session?: {
devAddr: string;
startedAt: string;
};
macState?: {
lastDevStatusReceivedAt?: string;
batteryPercentage?: number;
margin?: number;
};
}
/**
* Result of TTS API operations
*/
export interface TtsApiResult<T = unknown> {
success: boolean;
data?: T;
error?: string;
statusCode?: number;
}
/**
* Get TTS API configuration from environment
*/
function getTtsConfig(): TtsApiConfig {
return {
apiUrl: process.env.TTS_API_URL || '',
apiKey: process.env.TTS_API_KEY || '',
applicationId: process.env.TTS_APPLICATION_ID || '',
enabled: process.env.TTS_ENABLED === 'true',
};
}
/**
* Check if TTS integration is enabled
*/
export function isTtsEnabled(): boolean {
const config = getTtsConfig();
return config.enabled && !!config.apiUrl && !!config.apiKey;
}
/**
* Make an authenticated request to the TTS API
*/
async function ttsApiRequest<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: unknown
): Promise<TtsApiResult<T>> {
const config = getTtsConfig();
if (!config.enabled) {
logger.debug('TTS API is disabled, skipping request', { path });
return {
success: false,
error: 'TTS integration is disabled',
};
}
if (!config.apiUrl || !config.apiKey) {
logger.warn('TTS API configuration missing', {
hasApiUrl: !!config.apiUrl,
hasApiKey: !!config.apiKey,
});
return {
success: false,
error: 'TTS API configuration is incomplete',
};
}
const url = `${config.apiUrl}${path}`;
try {
logger.debug('Making TTS API request', { method, path });
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const responseText = await response.text();
let responseData: T | undefined;
try {
responseData = responseText ? JSON.parse(responseText) : undefined;
} catch {
// Response is not JSON
logger.debug('TTS API response is not JSON', { responseText: responseText.substring(0, 200) });
}
if (!response.ok) {
logger.warn('TTS API request failed', {
method,
path,
status: response.status,
statusText: response.statusText,
response: responseText.substring(0, 500),
});
return {
success: false,
error: `TTS API error: ${response.status} ${response.statusText}`,
statusCode: response.status,
data: responseData,
};
}
logger.debug('TTS API request successful', { method, path, status: response.status });
return {
success: true,
data: responseData,
statusCode: response.status,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('TTS API request error', {
method,
path,
error: errorMessage,
});
return {
success: false,
error: `TTS API request failed: ${errorMessage}`,
};
}
}
/**
* Register a device in The Things Stack
*
* @param device - Device registration details
* @returns Result of the registration
*/
export async function registerDevice(
device: TtsDeviceRegistration
): Promise<TtsApiResult<TtsDeviceStatus>> {
const config = getTtsConfig();
if (!isTtsEnabled()) {
logger.info('TTS disabled, skipping device registration', { devEui: device.devEui });
return {
success: true,
data: {
devEui: device.devEui,
deviceId: device.deviceId,
name: device.name,
},
};
}
logger.info('Registering device in TTS', {
devEui: device.devEui,
deviceId: device.deviceId,
});
const path = `/api/v3/applications/${config.applicationId}/devices`;
const body = {
end_device: {
ids: {
device_id: device.deviceId,
dev_eui: device.devEui.toUpperCase(),
join_eui: device.joinEui.toUpperCase(),
application_ids: {
application_id: config.applicationId,
},
},
name: device.name,
description: device.description || '',
lorawan_version: device.lorawanVersion || 'MAC_V1_0_3',
lorawan_phy_version: device.lorawanPhyVersion || 'PHY_V1_0_3_REV_A',
frequency_plan_id: device.frequencyPlanId || 'US_902_928_FSB_2',
supports_join: device.supportsJoin !== false,
supports_class_c: device.supportsClassC || false,
root_keys: {
app_key: {
key: device.appKey.toUpperCase(),
},
...(device.nwkKey && {
nwk_key: {
key: device.nwkKey.toUpperCase(),
},
}),
},
},
field_mask: {
paths: [
'name',
'description',
'lorawan_version',
'lorawan_phy_version',
'frequency_plan_id',
'supports_join',
'supports_class_c',
'root_keys.app_key.key',
...(device.nwkKey ? ['root_keys.nwk_key.key'] : []),
],
},
};
const result = await ttsApiRequest<TtsDeviceStatus>('POST', path, body);
if (result.success) {
logger.info('Device registered in TTS successfully', {
devEui: device.devEui,
deviceId: device.deviceId,
});
} else {
logger.error('Failed to register device in TTS', {
devEui: device.devEui,
error: result.error,
});
}
return result;
}
/**
* Delete a device from The Things Stack
*
* @param devEui - Device EUI
* @param deviceId - Device ID in TTS (optional, will use devEui if not provided)
* @returns Result of the deletion
*/
export async function deleteDevice(
devEui: string,
deviceId?: string
): Promise<TtsApiResult<void>> {
const config = getTtsConfig();
if (!isTtsEnabled()) {
logger.info('TTS disabled, skipping device deletion', { devEui });
return { success: true };
}
const id = deviceId || devEui.toLowerCase();
logger.info('Deleting device from TTS', { devEui, deviceId: id });
const path = `/api/v3/applications/${config.applicationId}/devices/${id}`;
const result = await ttsApiRequest<void>('DELETE', path);
if (result.success) {
logger.info('Device deleted from TTS successfully', { devEui });
} else if (result.statusCode === 404) {
// Device doesn't exist, consider it a success
logger.info('Device not found in TTS, considering deletion successful', { devEui });
return { success: true };
} else {
logger.error('Failed to delete device from TTS', {
devEui,
error: result.error,
});
}
return result;
}
/**
* Queue a downlink message to a device
*
* @param devEui - Device EUI
* @param payload - Downlink payload
* @param deviceId - Device ID in TTS (optional)
* @returns Result of the operation
*/
export async function sendDownlink(
devEui: string,
payload: TtsDownlinkPayload,
deviceId?: string
): Promise<TtsApiResult<{ correlationIds?: string[] }>> {
const config = getTtsConfig();
if (!isTtsEnabled()) {
logger.info('TTS disabled, skipping downlink', { devEui });
return {
success: false,
error: 'TTS integration is disabled',
};
}
const id = deviceId || devEui.toLowerCase();
logger.info('Sending downlink to device via TTS', {
devEui,
deviceId: id,
fPort: payload.fPort,
confirmed: payload.confirmed,
});
const path = `/api/v3/as/applications/${config.applicationId}/devices/${id}/down/push`;
const body = {
downlinks: [
{
f_port: payload.fPort,
frm_payload: payload.frmPayload,
confirmed: payload.confirmed || false,
priority: payload.priority || 'NORMAL',
...(payload.classBC && {
class_b_c: payload.classBC,
}),
},
],
};
const result = await ttsApiRequest<{ correlation_ids?: string[] }>('POST', path, body);
if (result.success) {
logger.info('Downlink queued successfully', {
devEui,
correlationIds: result.data?.correlation_ids,
});
return {
success: true,
data: {
correlationIds: result.data?.correlation_ids,
},
};
} else {
logger.error('Failed to send downlink', {
devEui,
error: result.error,
});
return {
success: false,
error: result.error,
statusCode: result.statusCode,
};
}
}
/**
* Get device status from The Things Stack
*
* @param devEui - Device EUI
* @param deviceId - Device ID in TTS (optional)
* @returns Device status information
*/
export async function getDeviceStatus(
devEui: string,
deviceId?: string
): Promise<TtsApiResult<TtsDeviceStatus>> {
const config = getTtsConfig();
if (!isTtsEnabled()) {
logger.info('TTS disabled, skipping device status request', { devEui });
return {
success: false,
error: 'TTS integration is disabled',
};
}
const id = deviceId || devEui.toLowerCase();
logger.debug('Getting device status from TTS', { devEui, deviceId: id });
const path = `/api/v3/applications/${config.applicationId}/devices/${id}?field_mask=name,description,created_at,updated_at,session,mac_state`;
const result = await ttsApiRequest<{
ids: {
device_id: string;
dev_eui: string;
};
name: string;
description?: string;
created_at?: string;
updated_at?: string;
session?: {
dev_addr: string;
started_at: string;
};
mac_state?: {
last_dev_status_received_at?: string;
battery_percentage?: number;
margin?: number;
};
}>('GET', path);
if (result.success && result.data) {
const data = result.data;
const status: TtsDeviceStatus = {
devEui: data.ids.dev_eui,
deviceId: data.ids.device_id,
name: data.name,
description: data.description,
createdAt: data.created_at,
updatedAt: data.updated_at,
session: data.session
? {
devAddr: data.session.dev_addr,
startedAt: data.session.started_at,
}
: undefined,
macState: data.mac_state
? {
lastDevStatusReceivedAt: data.mac_state.last_dev_status_received_at,
batteryPercentage: data.mac_state.battery_percentage,
margin: data.mac_state.margin,
}
: undefined,
};
logger.debug('Device status retrieved successfully', {
devEui,
hasSession: !!status.session,
});
return {
success: true,
data: status,
};
}
if (result.statusCode === 404) {
logger.info('Device not found in TTS', { devEui });
return {
success: false,
error: 'Device not found in TTS',
statusCode: 404,
};
}
return {
success: false,
error: result.error,
statusCode: result.statusCode,
};
}
/**
* Encode a hex string to base64 for downlink payloads
*/
export function hexToBase64(hex: string): string {
const cleanHex = hex.replace(/\s/g, '');
const buffer = Buffer.from(cleanHex, 'hex');
return buffer.toString('base64');
}
/**
* Decode a base64 string to hex for debugging
*/
export function base64ToHex(base64: string): string {
const buffer = Buffer.from(base64, 'base64');
return buffer.toString('hex');
}
export default {
isTtsEnabled,
registerDevice,
deleteDevice,
sendDownlink,
getDeviceStatus,
hexToBase64,
base64ToHex,
};

View File

@@ -0,0 +1,545 @@
import { query, getClient } from '../../config/database';
import logger from '../../utils/logger';
import { decodeWithFallback, DecodedPayload } from './payloadDecoder.service';
import {
TtsUplinkPayload,
TtsJoinPayload,
TtsDownlinkAckPayload,
} from '../../validators/tts.validator';
/**
* Device record structure from devices table
*/
interface DeviceRecord {
id: number;
dev_eui: string;
device_type: string;
meter_id: number | null;
tts_status: string;
tts_last_seen: Date | null;
}
/**
* Result of processing an uplink
*/
export interface UplinkProcessingResult {
success: boolean;
logId: number | null;
deviceId: number | null;
meterId: number | null;
readingId: number | null;
decodedPayload: DecodedPayload | null;
error: string | null;
}
/**
* Result of processing a join event
*/
export interface JoinProcessingResult {
success: boolean;
deviceId: number | null;
error: string | null;
}
/**
* Result of processing a downlink ack
*/
export interface DownlinkAckProcessingResult {
success: boolean;
logId: number | null;
deviceId: number | null;
error: string | null;
}
/**
* Log raw uplink payload to the database
*/
async function logUplinkPayload(payload: TtsUplinkPayload): Promise<number> {
const { end_device_ids, uplink_message, received_at } = payload;
// Extract metadata from first gateway
const rxMeta = uplink_message.rx_metadata?.[0];
const insertQuery = `
INSERT INTO tts_uplink_logs (
dev_eui,
device_id,
application_id,
raw_payload,
decoded_payload,
f_port,
f_cnt,
rssi,
snr,
gateway_id,
received_at,
processed,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, false, NOW())
RETURNING id
`;
const values = [
end_device_ids.dev_eui.toLowerCase(),
end_device_ids.device_id,
end_device_ids.application_ids.application_id,
uplink_message.frm_payload,
uplink_message.decoded_payload ? JSON.stringify(uplink_message.decoded_payload) : null,
uplink_message.f_port,
uplink_message.f_cnt ?? null,
rxMeta?.rssi ?? rxMeta?.channel_rssi ?? null,
rxMeta?.snr ?? null,
rxMeta?.gateway_ids?.gateway_id ?? null,
received_at,
];
const result = await query<{ id: number }>(insertQuery, values);
return result.rows[0].id;
}
/**
* Find device by dev_eui
*/
async function findDeviceByDevEui(devEui: string): Promise<DeviceRecord | null> {
const selectQuery = `
SELECT id, dev_eui, device_type, meter_id, tts_status, tts_last_seen
FROM devices
WHERE LOWER(dev_eui) = LOWER($1)
LIMIT 1
`;
const result = await query<DeviceRecord>(selectQuery, [devEui]);
return result.rows[0] || null;
}
/**
* Create a new meter reading record
*/
async function createMeterReading(
meterId: number,
decodedPayload: DecodedPayload,
receivedAt: string
): Promise<number> {
const insertQuery = `
INSERT INTO meter_readings (
meter_id,
reading_value,
reading_date,
reading_type,
battery_level,
signal_strength,
is_anomaly,
raw_data,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
RETURNING id
`;
const values = [
meterId,
decodedPayload.readingValue,
receivedAt,
'automatic',
decodedPayload.batteryLevel,
decodedPayload.signalStrength,
decodedPayload.leakDetected || decodedPayload.tamperAlert,
JSON.stringify(decodedPayload.rawFields),
];
const result = await query<{ id: number }>(insertQuery, values);
return result.rows[0].id;
}
/**
* Update meter's last reading information
*/
async function updateMeterLastReading(
meterId: number,
readingValue: number,
readingDate: string
): Promise<void> {
const updateQuery = `
UPDATE meters
SET
last_reading_value = $1,
last_reading_date = $2,
updated_at = NOW()
WHERE id = $3
`;
await query(updateQuery, [readingValue, readingDate, meterId]);
}
/**
* Update device's TTS last seen timestamp
*/
async function updateDeviceTtsLastSeen(deviceId: number): Promise<void> {
const updateQuery = `
UPDATE devices
SET
tts_last_seen = NOW(),
last_communication = NOW(),
status = 'online',
updated_at = NOW()
WHERE id = $1
`;
await query(updateQuery, [deviceId]);
}
/**
* Mark uplink log as processed
*/
async function markLogAsProcessed(
logId: number,
errorMessage: string | null = null
): Promise<void> {
const updateQuery = `
UPDATE tts_uplink_logs
SET
processed = true,
processed_at = NOW(),
error_message = $1
WHERE id = $2
`;
await query(updateQuery, [errorMessage, logId]);
}
/**
* Process an uplink webhook from TTS
*
* Steps:
* 1. Log raw payload to tts_uplink_logs
* 2. Find device by dev_eui
* 3. If device found, decode payload
* 4. Create meter_reading record
* 5. Update meter's last_reading_value and last_reading_at
* 6. Update device's tts_last_seen
* 7. Mark log as processed
*/
export async function processUplink(payload: TtsUplinkPayload): Promise<UplinkProcessingResult> {
const result: UplinkProcessingResult = {
success: false,
logId: null,
deviceId: null,
meterId: null,
readingId: null,
decodedPayload: null,
error: null,
};
const client = await getClient();
try {
await client.query('BEGIN');
// Step 1: Log raw payload
logger.info('Processing TTS uplink', {
devEui: payload.end_device_ids.dev_eui,
fPort: payload.uplink_message.f_port,
});
result.logId = await logUplinkPayload(payload);
logger.debug('Uplink logged', { logId: result.logId });
// Step 2: Find device by dev_eui
const device = await findDeviceByDevEui(payload.end_device_ids.dev_eui);
if (!device) {
logger.warn('Device not found for uplink', {
devEui: payload.end_device_ids.dev_eui,
logId: result.logId,
});
await markLogAsProcessed(result.logId, 'Device not found');
await client.query('COMMIT');
result.error = 'Device not found';
return result;
}
result.deviceId = device.id;
logger.debug('Device found', { deviceId: device.id, deviceType: device.device_type });
// Step 3: Decode payload
const decodedPayload = decodeWithFallback(
payload.uplink_message.frm_payload,
payload.uplink_message.decoded_payload,
device.device_type
);
result.decodedPayload = decodedPayload;
if (!decodedPayload.decodingSuccess) {
logger.warn('Failed to decode uplink payload', {
devEui: payload.end_device_ids.dev_eui,
error: decodedPayload.decodingError,
});
await markLogAsProcessed(result.logId, decodedPayload.decodingError);
await updateDeviceTtsLastSeen(device.id);
await client.query('COMMIT');
result.error = decodedPayload.decodingError;
return result;
}
// Step 4: Create meter reading (if device has associated meter)
if (device.meter_id && decodedPayload.readingValue !== null) {
result.meterId = device.meter_id;
result.readingId = await createMeterReading(
device.meter_id,
decodedPayload,
payload.received_at
);
logger.debug('Meter reading created', {
readingId: result.readingId,
meterId: device.meter_id,
value: decodedPayload.readingValue,
});
// Step 5: Update meter's last reading
await updateMeterLastReading(
device.meter_id,
decodedPayload.readingValue,
payload.received_at
);
logger.debug('Meter last reading updated', { meterId: device.meter_id });
} else if (!device.meter_id) {
logger.debug('Device has no associated meter', { deviceId: device.id });
}
// Step 6: Update device's TTS last seen
await updateDeviceTtsLastSeen(device.id);
// Step 7: Mark log as processed
await markLogAsProcessed(result.logId);
await client.query('COMMIT');
result.success = true;
logger.info('Uplink processed successfully', {
logId: result.logId,
deviceId: result.deviceId,
meterId: result.meterId,
readingId: result.readingId,
readingValue: decodedPayload.readingValue,
});
return result;
} catch (error) {
await client.query('ROLLBACK');
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to process uplink', {
devEui: payload.end_device_ids.dev_eui,
error: errorMessage,
logId: result.logId,
});
// Try to mark log as failed if we have a log ID
if (result.logId) {
try {
await markLogAsProcessed(result.logId, errorMessage);
} catch (markError) {
logger.error('Failed to mark log as processed', { logId: result.logId });
}
}
result.error = errorMessage;
return result;
} finally {
client.release();
}
}
/**
* Process a join webhook from TTS
*
* Steps:
* 1. Find device by dev_eui
* 2. Update device tts_status to 'JOINED'
* 3. Update tts_last_seen
*/
export async function processJoin(payload: TtsJoinPayload): Promise<JoinProcessingResult> {
const result: JoinProcessingResult = {
success: false,
deviceId: null,
error: null,
};
try {
logger.info('Processing TTS join event', {
devEui: payload.end_device_ids.dev_eui,
deviceId: payload.end_device_ids.device_id,
});
// Step 1: Find device by dev_eui
const device = await findDeviceByDevEui(payload.end_device_ids.dev_eui);
if (!device) {
logger.warn('Device not found for join event', {
devEui: payload.end_device_ids.dev_eui,
});
result.error = 'Device not found';
return result;
}
result.deviceId = device.id;
// Step 2 & 3: Update device status and last seen
const updateQuery = `
UPDATE devices
SET
tts_status = 'JOINED',
tts_last_seen = NOW(),
status = 'online',
last_communication = NOW(),
updated_at = NOW()
WHERE id = $1
`;
await query(updateQuery, [device.id]);
result.success = true;
logger.info('Join event processed successfully', {
deviceId: device.id,
devEui: payload.end_device_ids.dev_eui,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to process join event', {
devEui: payload.end_device_ids.dev_eui,
error: errorMessage,
});
result.error = errorMessage;
return result;
}
}
/**
* Log downlink confirmation event
*/
async function logDownlinkEvent(
devEui: string,
eventType: 'ack' | 'sent' | 'failed' | 'queued',
payload: TtsDownlinkAckPayload
): Promise<number> {
const insertQuery = `
INSERT INTO tts_downlink_logs (
dev_eui,
device_id,
application_id,
event_type,
f_port,
f_cnt,
correlation_ids,
received_at,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
RETURNING id
`;
// Extract details from the appropriate event field
const eventData =
payload.downlink_ack ||
payload.downlink_sent ||
payload.downlink_failed?.downlink ||
payload.downlink_queued;
const values = [
devEui.toLowerCase(),
payload.end_device_ids.device_id,
payload.end_device_ids.application_ids.application_id,
eventType,
eventData?.f_port ?? null,
eventData?.f_cnt ?? null,
payload.correlation_ids ? JSON.stringify(payload.correlation_ids) : null,
payload.received_at,
];
const result = await query<{ id: number }>(insertQuery, values);
return result.rows[0].id;
}
/**
* Process a downlink acknowledgment webhook from TTS
*
* Steps:
* 1. Log the downlink confirmation
* 2. Update device status if needed
*/
export async function processDownlinkAck(
payload: TtsDownlinkAckPayload
): Promise<DownlinkAckProcessingResult> {
const result: DownlinkAckProcessingResult = {
success: false,
logId: null,
deviceId: null,
error: null,
};
try {
// Determine event type
let eventType: 'ack' | 'sent' | 'failed' | 'queued' = 'ack';
if (payload.downlink_sent) eventType = 'sent';
if (payload.downlink_failed) eventType = 'failed';
if (payload.downlink_queued) eventType = 'queued';
logger.info('Processing TTS downlink event', {
devEui: payload.end_device_ids.dev_eui,
eventType,
});
// Step 1: Log the downlink event
result.logId = await logDownlinkEvent(
payload.end_device_ids.dev_eui,
eventType,
payload
);
// Step 2: Update device status if needed
const device = await findDeviceByDevEui(payload.end_device_ids.dev_eui);
if (device) {
result.deviceId = device.id;
// Update last seen on any downlink activity
await updateDeviceTtsLastSeen(device.id);
// If downlink failed, log warning but don't change device status
if (payload.downlink_failed) {
logger.warn('Downlink failed', {
deviceId: device.id,
devEui: payload.end_device_ids.dev_eui,
error: payload.downlink_failed.error,
});
}
}
result.success = true;
logger.info('Downlink event processed successfully', {
logId: result.logId,
deviceId: result.deviceId,
eventType,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to process downlink event', {
devEui: payload.end_device_ids.dev_eui,
error: errorMessage,
});
result.error = errorMessage;
return result;
}
}
export default {
processUplink,
processJoin,
processDownlinkAck,
};

View File

@@ -0,0 +1,436 @@
import { query } from '../config/database';
import { hashPassword, comparePassword } from '../utils/password';
import { User, UserPublic, PaginationParams } from '../types';
/**
* User filter options
*/
export interface UserFilter {
role_id?: number;
is_active?: boolean;
search?: string;
}
/**
* User service response with pagination
*/
export interface PaginatedUsers {
users: UserPublic[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Get all users with optional filtering and pagination
* @param filters - Optional filters (role_id, is_active)
* @param pagination - Optional pagination parameters
* @returns Paginated list of users without password_hash
*/
export async function getAll(
filters?: UserFilter,
pagination?: PaginationParams
): Promise<PaginatedUsers> {
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 clauses
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters?.role_id !== undefined) {
conditions.push(`u.role_id = $${paramIndex}`);
params.push(filters.role_id);
paramIndex++;
}
if (filters?.is_active !== undefined) {
conditions.push(`u.is_active = $${paramIndex}`);
params.push(filters.is_active);
paramIndex++;
}
if (filters?.search) {
conditions.push(
`(u.first_name ILIKE $${paramIndex} OR u.last_name ILIKE $${paramIndex} OR u.email ILIKE $${paramIndex})`
);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Validate sortBy to prevent SQL injection
const allowedSortColumns = ['created_at', 'updated_at', 'email', 'first_name', 'last_name'];
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM users u
${whereClause}
`;
const countResult = await query<{ total: string }>(countQuery, params);
const total = parseInt(countResult.rows[0].total, 10);
// Get users with role name
const usersQuery = `
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.role_id,
r.name as role_name,
r.description as role_description,
u.is_active,
u.last_login,
u.created_at,
u.updated_at
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
${whereClause}
ORDER BY u.${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const usersResult = await query(usersQuery, [...params, limit, offset]);
const users: UserPublic[] = usersResult.rows.map((row) => ({
id: row.id,
email: row.email,
first_name: row.first_name,
last_name: row.last_name,
role_id: row.role_id,
role: row.role_name
? {
id: row.role_id,
name: row.role_name,
description: row.role_description,
permissions: [],
created_at: row.created_at,
updated_at: row.updated_at,
}
: undefined,
is_active: row.is_active,
last_login: row.last_login,
created_at: row.created_at,
updated_at: row.updated_at,
}));
const totalPages = Math.ceil(total / limit);
return {
users,
pagination: {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Get a single user by ID without password_hash, include role name
* @param id - User ID
* @returns User without password_hash or null if not found
*/
export async function getById(id: number): Promise<UserPublic | null> {
const result = await query(
`
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.role_id,
r.name as role_name,
r.description as role_description,
r.permissions as role_permissions,
u.is_active,
u.last_login,
u.created_at,
u.updated_at
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = $1
`,
[id]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
id: row.id,
email: row.email,
first_name: row.first_name,
last_name: row.last_name,
role_id: row.role_id,
role: row.role_name
? {
id: row.role_id,
name: row.role_name,
description: row.role_description,
permissions: row.role_permissions || [],
created_at: row.created_at,
updated_at: row.updated_at,
}
: undefined,
is_active: row.is_active,
last_login: row.last_login,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* Get a user by email (internal use, includes password_hash)
* @param email - User email
* @returns Full user record or null if not found
*/
export async function getByEmail(email: string): Promise<User | null> {
const result = await query<User>(
`
SELECT
u.*,
r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.email = $1
`,
[email.toLowerCase()]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
}
/**
* Create a new user with hashed password
* @param data - User data including password
* @returns Created user without password_hash
*/
export async function create(data: {
email: string;
password: string;
first_name: string;
last_name: string;
role_id: number;
is_active?: boolean;
}): Promise<UserPublic> {
// Check if email already exists
const existingUser = await getByEmail(data.email);
if (existingUser) {
throw new Error('Email already in use');
}
// Hash password
const password_hash = await hashPassword(data.password);
const result = await query(
`
INSERT INTO users (email, password_hash, first_name, last_name, role_id, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, first_name, last_name, role_id, is_active, last_login, created_at, updated_at
`,
[
data.email.toLowerCase(),
password_hash,
data.first_name,
data.last_name,
data.role_id,
data.is_active ?? true,
]
);
const user = result.rows[0];
// Fetch complete user with role
return (await getById(user.id)) as UserPublic;
}
/**
* Update a user (hash password if changed)
* @param id - User ID
* @param data - Fields to update
* @returns Updated user without password_hash
*/
export async function update(
id: number,
data: {
email?: string;
first_name?: string;
last_name?: string;
role_id?: number;
is_active?: boolean;
}
): Promise<UserPublic | null> {
// Check if user exists
const existingUser = await getById(id);
if (!existingUser) {
return null;
}
// If email is being changed, check it's not already in use
if (data.email && data.email.toLowerCase() !== existingUser.email) {
const emailUser = await getByEmail(data.email);
if (emailUser) {
throw new Error('Email already in use');
}
}
// Build UPDATE query dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.email !== undefined) {
updates.push(`email = $${paramIndex}`);
params.push(data.email.toLowerCase());
paramIndex++;
}
if (data.first_name !== undefined) {
updates.push(`first_name = $${paramIndex}`);
params.push(data.first_name);
paramIndex++;
}
if (data.last_name !== undefined) {
updates.push(`last_name = $${paramIndex}`);
params.push(data.last_name);
paramIndex++;
}
if (data.role_id !== undefined) {
updates.push(`role_id = $${paramIndex}`);
params.push(data.role_id);
paramIndex++;
}
if (data.is_active !== undefined) {
updates.push(`is_active = $${paramIndex}`);
params.push(data.is_active);
paramIndex++;
}
if (updates.length === 0) {
return existingUser;
}
updates.push(`updated_at = NOW()`);
params.push(id);
const updateQuery = `
UPDATE users
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id
`;
await query(updateQuery, params);
return await getById(id);
}
/**
* Soft delete a user by setting is_active = false
* @param id - User ID
* @returns True if deleted, false if user not found
*/
export async function deleteUser(id: number): Promise<boolean> {
const result = await query(
`
UPDATE users
SET is_active = false, updated_at = NOW()
WHERE id = $1
RETURNING id
`,
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}
/**
* Change user password after verifying current password
* @param id - User ID
* @param currentPassword - Current password for verification
* @param newPassword - New password to set
* @returns True if password changed, throws error if verification fails
*/
export async function changePassword(
id: number,
currentPassword: string,
newPassword: string
): Promise<boolean> {
// Get user with password hash
const result = await query<{ password_hash: string }>(
`SELECT password_hash FROM users WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
throw new Error('User not found');
}
const user = result.rows[0];
// Verify current password
const isValidPassword = await comparePassword(currentPassword, user.password_hash);
if (!isValidPassword) {
throw new Error('Current password is incorrect');
}
// Hash new password and update
const newPasswordHash = await hashPassword(newPassword);
await query(
`
UPDATE users
SET password_hash = $1, updated_at = NOW()
WHERE id = $2
`,
[newPasswordHash, id]
);
return true;
}
/**
* Update the last_login timestamp for a user
* @param id - User ID
* @returns True if updated, false if user not found
*/
export async function updateLastLogin(id: number): Promise<boolean> {
const result = await query(
`
UPDATE users
SET last_login = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING id
`,
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}

View File

@@ -0,0 +1,342 @@
import { Request } from 'express';
// ============================================
// Base Types
// ============================================
export interface Timestamps {
created_at: Date;
updated_at: Date;
}
// ============================================
// User & Authentication Types
// ============================================
export interface Role {
id: number;
name: string;
description: string | null;
permissions: string[];
created_at: Date;
updated_at: Date;
}
export interface User {
id: number;
email: string;
password_hash: string;
first_name: string;
last_name: string;
role_id: number;
role?: Role;
is_active: boolean;
last_login: Date | null;
created_at: Date;
updated_at: Date;
}
export interface UserPublic {
id: number;
email: string;
first_name: string;
last_name: string;
role_id: number;
role?: Role;
is_active: boolean;
last_login: Date | null;
created_at: Date;
updated_at: Date;
}
export interface JwtPayload {
userId: number;
email: string;
roleId: number;
roleName: string;
iat?: number;
exp?: number;
}
export interface AuthenticatedRequest extends Request {
user?: JwtPayload;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
// ============================================
// Project Types
// ============================================
export interface Project {
id: number;
name: string;
description: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
is_active: boolean;
created_by: number;
created_at: Date;
updated_at: Date;
}
// ============================================
// Infrastructure Types
// ============================================
export interface Concentrator {
id: number;
project_id: number;
project?: Project;
name: string;
serial_number: string;
model: string | null;
firmware_version: string | null;
ip_address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
last_communication: Date | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface Gateway {
id: number;
concentrator_id: number;
concentrator?: Concentrator;
name: string;
serial_number: string;
model: string | null;
firmware_version: string | null;
mac_address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
last_communication: Date | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface Device {
id: number;
gateway_id: number;
gateway?: Gateway;
name: string;
serial_number: string;
device_type: string;
model: string | null;
firmware_version: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
last_communication: Date | null;
battery_level: number | null;
signal_strength: number | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
// ============================================
// Meter Types
// ============================================
export type MeterStatus = 'active' | 'inactive' | 'maintenance' | 'faulty' | 'replaced';
export interface Meter {
id: number;
device_id: number;
device?: Device;
meter_number: string;
customer_name: string | null;
customer_address: string | null;
customer_phone: string | null;
customer_email: string | null;
meter_type: 'residential' | 'commercial' | 'industrial';
installation_date: Date | null;
last_reading_date: Date | null;
last_reading_value: number | null;
status: MeterStatus;
latitude: number | null;
longitude: number | null;
notes: string | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface MeterReading {
id: number;
meter_id: number;
meter?: Meter;
reading_value: number;
reading_date: Date;
consumption: number | null;
unit: string;
reading_type: 'automatic' | 'manual' | 'estimated';
battery_level: number | null;
signal_strength: number | null;
is_anomaly: boolean;
anomaly_type: string | null;
raw_data: Record<string, unknown> | null;
created_at: Date;
}
// ============================================
// API Response Types
// ============================================
export interface ApiResponse<T> {
success: boolean;
message?: string;
data?: T;
error?: string;
errors?: ValidationError[];
}
export interface ValidationError {
field: string;
message: string;
}
export interface PaginatedResponse<T> {
success: boolean;
message?: string;
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface PaginationParams {
page: number;
limit: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
// ============================================
// Filter Types
// ============================================
export interface MeterFilter {
project_id?: number;
concentrator_id?: number;
gateway_id?: number;
device_id?: number;
status?: MeterStatus;
meter_type?: 'residential' | 'commercial' | 'industrial';
search?: string;
}
export interface ReadingFilter {
meter_id?: number;
start_date?: Date;
end_date?: Date;
reading_type?: 'automatic' | 'manual' | 'estimated';
is_anomaly?: boolean;
}
// ============================================
// Dashboard & Statistics Types
// ============================================
export interface DashboardStats {
total_projects: number;
total_concentrators: number;
total_gateways: number;
total_devices: number;
total_meters: number;
active_meters: number;
total_readings_today: number;
total_consumption_today: number;
devices_online: number;
devices_offline: number;
}
export interface ConsumptionSummary {
period: string;
total_consumption: number;
average_consumption: number;
max_consumption: number;
min_consumption: number;
reading_count: number;
}
// ============================================
// TTS (The Things Stack) Types
// ============================================
export type TtsDeviceStatus = 'PENDING' | 'REGISTERED' | 'JOINED' | 'ACTIVE' | 'INACTIVE' | 'ERROR';
export interface TtsDevice extends Device {
dev_eui: string;
join_eui: string | null;
app_key: string | null;
nwk_key: string | null;
tts_device_id: string | null;
tts_application_id: string | null;
tts_status: TtsDeviceStatus;
tts_last_seen: Date | null;
tts_registered_at: Date | null;
}
export interface TtsUplinkLog {
id: number;
dev_eui: string;
device_id: number | null;
application_id: string;
raw_payload: string;
decoded_payload: Record<string, unknown> | null;
f_port: number;
f_cnt: number | null;
rssi: number | null;
snr: number | null;
gateway_id: string | null;
received_at: Date;
processed: boolean;
processed_at: Date | null;
error_message: string | null;
created_at: Date;
}
export interface TtsDownlinkLog {
id: number;
dev_eui: string;
device_id: number | null;
application_id: string;
event_type: 'ack' | 'sent' | 'failed' | 'queued';
f_port: number | null;
f_cnt: number | null;
correlation_ids: string[] | null;
received_at: Date;
created_at: Date;
}
export interface TtsWebhookPayload {
end_device_ids: {
device_id: string;
dev_eui: string;
join_eui?: string;
application_ids: {
application_id: string;
};
};
correlation_ids?: string[];
received_at: string;
}

View File

137
water-api/src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,137 @@
import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
import config from '../config';
import logger from './logger';
interface TokenPayload {
id: string;
email?: string;
role?: string;
[key: string]: unknown;
}
/**
* Generate an access token
* @param payload - Data to encode in the token
* @returns Signed JWT access token
*/
export const generateAccessToken = (payload: TokenPayload): string => {
const options: SignOptions = {
expiresIn: config.jwt.accessTokenExpiresIn as SignOptions['expiresIn'],
algorithm: 'HS256',
};
try {
const token = jwt.sign(payload, config.jwt.accessTokenSecret, options);
return token;
} catch (error) {
logger.error('Error generating access token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to generate access token');
}
};
/**
* Generate a refresh token
* @param payload - Data to encode in the token
* @returns Signed JWT refresh token
*/
export const generateRefreshToken = (payload: TokenPayload): string => {
const options: SignOptions = {
expiresIn: config.jwt.refreshTokenExpiresIn as SignOptions['expiresIn'],
algorithm: 'HS256',
};
try {
const token = jwt.sign(payload, config.jwt.refreshTokenSecret, options);
return token;
} catch (error) {
logger.error('Error generating refresh token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to generate refresh token');
}
};
/**
* Verify an access token
* @param token - JWT access token to verify
* @returns Decoded payload if valid, null if invalid or expired
*/
export const verifyAccessToken = (token: string): JwtPayload | null => {
const options: VerifyOptions = {
algorithms: ['HS256'],
};
try {
const decoded = jwt.verify(
token,
config.jwt.accessTokenSecret,
options
) as JwtPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.debug('Access token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
logger.debug('Invalid access token', {
error: error.message,
});
} else {
logger.error('Error verifying access token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return null;
}
};
/**
* Verify a refresh token
* @param token - JWT refresh token to verify
* @returns Decoded payload if valid, null if invalid or expired
*/
export const verifyRefreshToken = (token: string): JwtPayload | null => {
const options: VerifyOptions = {
algorithms: ['HS256'],
};
try {
const decoded = jwt.verify(
token,
config.jwt.refreshTokenSecret,
options
) as JwtPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.debug('Refresh token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
logger.debug('Invalid refresh token', {
error: error.message,
});
} else {
logger.error('Error verifying refresh token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return null;
}
};
/**
* Decode a token without verification (for debugging)
* @param token - JWT token to decode
* @returns Decoded payload or null
*/
export const decodeToken = (token: string): JwtPayload | null => {
try {
const decoded = jwt.decode(token) as JwtPayload | null;
return decoded;
} catch (error) {
logger.error('Error decoding token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
};

View File

@@ -0,0 +1,39 @@
import winston from 'winston';
const { combine, timestamp, printf, colorize, errors } = winston.format;
const logFormat = printf(({ level, message, timestamp, stack }) => {
return `${timestamp} [${level}]: ${stack || message}`;
});
const getLogLevel = (): string => {
const env = process.env.NODE_ENV || 'development';
switch (env) {
case 'production':
return 'warn';
case 'test':
return 'error';
default:
return 'debug';
}
};
const logger = winston.createLogger({
level: getLogLevel(),
format: combine(
errors({ stack: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' })
),
transports: [
new winston.transports.Console({
format: combine(
colorize({ all: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
}),
],
exitOnError: false,
});
export default logger;

View File

@@ -0,0 +1,43 @@
import bcrypt from 'bcrypt';
import logger from './logger';
const SALT_ROUNDS = 12;
/**
* Hash a password using bcrypt
* @param password - Plain text password to hash
* @returns Hashed password
*/
export const hashPassword = async (password: string): Promise<string> => {
try {
const salt = await bcrypt.genSalt(SALT_ROUNDS);
const hashedPassword = await bcrypt.hash(password, salt);
return hashedPassword;
} catch (error) {
logger.error('Error hashing password', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to hash password');
}
};
/**
* Compare a plain text password with a hashed password
* @param password - Plain text password to compare
* @param hash - Hashed password to compare against
* @returns True if passwords match, false otherwise
*/
export const comparePassword = async (
password: string,
hash: string
): Promise<boolean> => {
try {
const isMatch = await bcrypt.compare(password, hash);
return isMatch;
} catch (error) {
logger.error('Error comparing passwords', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new Error('Failed to compare passwords');
}
};

View File

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Schema for login request validation
* - email: must be valid email format
* - password: minimum 6 characters
*/
export const loginSchema = z.object({
email: z
.string({ required_error: 'Email is required' })
.email('Invalid email format'),
password: z
.string({ required_error: 'Password is required' })
.min(6, 'Password must be at least 6 characters'),
});
/**
* Schema for refresh token request validation
* - refreshToken: required string
*/
export const refreshSchema = z.object({
refreshToken: z
.string({ required_error: 'Refresh token is required' })
.min(1, 'Refresh token cannot be empty'),
});
/**
* Type definitions derived from schemas
*/
export type LoginInput = z.infer<typeof loginSchema>;
export type RefreshInput = z.infer<typeof refreshSchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
error: 'Validation failed',
details: errors,
});
return;
}
// Replace body with validated and typed data
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares
*/
export const validateLogin = validate(loginSchema);
export const validateRefresh = validate(refreshSchema);

View File

@@ -0,0 +1,132 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Schema for creating a concentrator
* - serial_number: required, unique identifier
* - name: optional display name
* - project_id: required UUID, links to project
* - location: optional location description
* - status: optional status enum
* - ip_address: optional IP address
* - firmware_version: optional firmware version
*/
export const createConcentratorSchema = z.object({
serial_number: z
.string({ required_error: 'Serial number is required' })
.min(1, 'Serial number cannot be empty'),
name: z
.string()
.min(1, 'Name cannot be empty')
.optional(),
project_id: z
.string({ required_error: 'Project ID is required' })
.uuid('Project ID must be a valid UUID'),
location: z
.string()
.optional(),
type: z
.enum(['LORA', 'LORAWAN', 'GRANDES'])
.optional()
.default('LORA'),
status: z
.enum(['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'OFFLINE'])
.optional()
.default('ACTIVE'),
ip_address: z
.string()
.optional()
.nullable()
.transform(val => (!val || val === '') ? null : val)
.refine(val => val === null || /^(\d{1,3}\.){3}\d{1,3}$/.test(val), {
message: 'Invalid IP address format',
}),
firmware_version: z
.string()
.optional()
.nullable()
.transform(val => (!val || val === '') ? null : val),
});
/**
* Schema for updating a concentrator
* All fields are optional
*/
export const updateConcentratorSchema = z.object({
serial_number: z
.string()
.min(1, 'Serial number cannot be empty')
.optional(),
name: z
.string()
.min(1, 'Name cannot be empty')
.optional()
.nullable(),
project_id: z
.string()
.uuid('Project ID must be a valid UUID')
.optional(),
location: z
.string()
.optional()
.nullable(),
type: z
.enum(['LORA', 'LORAWAN', 'GRANDES'])
.optional(),
status: z
.enum(['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'OFFLINE'])
.optional(),
ip_address: z
.string()
.optional()
.nullable()
.transform(val => (!val || val === '') ? null : val)
.refine(val => val === null || val === undefined || /^(\d{1,3}\.){3}\d{1,3}$/.test(val), {
message: 'Invalid IP address format',
}),
firmware_version: z
.string()
.optional()
.nullable()
.transform(val => (!val || val === '') ? null : val),
});
/**
* Type definitions derived from schemas
*/
export type CreateConcentratorInput = z.infer<typeof createConcentratorSchema>;
export type UpdateConcentratorInput = z.infer<typeof updateConcentratorSchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
details: errors,
});
return;
}
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares
*/
export const validateCreateConcentrator = validate(createConcentratorSchema);
export const validateUpdateConcentrator = validate(updateConcentratorSchema);

View File

@@ -0,0 +1,144 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Schema for creating a device
* - dev_eui: required, LoRaWAN DevEUI
* - name: optional display name
* - device_type: optional device type classification
* - project_id: required UUID, links to project
* - gateway_id: optional UUID, links to gateway
* - status: optional status enum
* - tts_device_id: optional The Things Stack device ID
* - app_key: optional LoRaWAN AppKey
* - join_eui: optional LoRaWAN JoinEUI/AppEUI
*/
export const createDeviceSchema = z.object({
dev_eui: z
.string({ required_error: 'DevEUI is required' })
.min(1, 'DevEUI cannot be empty')
.regex(/^[0-9A-Fa-f]{16}$/, 'DevEUI must be a 16-character hexadecimal string'),
name: z
.string()
.min(1, 'Name cannot be empty')
.optional(),
device_type: z
.string()
.min(1, 'Device type cannot be empty')
.optional()
.nullable(),
project_id: z
.string({ required_error: 'Project ID is required' })
.uuid('Project ID must be a valid UUID'),
gateway_id: z
.string()
.uuid('Gateway ID must be a valid UUID')
.optional()
.nullable(),
status: z
.enum(['online', 'offline', 'maintenance', 'unknown'])
.optional()
.default('unknown'),
tts_device_id: z
.string()
.optional()
.nullable(),
app_key: z
.string()
.regex(/^[0-9A-Fa-f]{32}$/, 'AppKey must be a 32-character hexadecimal string')
.optional()
.nullable(),
join_eui: z
.string()
.regex(/^[0-9A-Fa-f]{16}$/, 'JoinEUI must be a 16-character hexadecimal string')
.optional()
.nullable(),
});
/**
* Schema for updating a device
* All fields are optional
*/
export const updateDeviceSchema = z.object({
dev_eui: z
.string()
.min(1, 'DevEUI cannot be empty')
.regex(/^[0-9A-Fa-f]{16}$/, 'DevEUI must be a 16-character hexadecimal string')
.optional(),
name: z
.string()
.min(1, 'Name cannot be empty')
.optional()
.nullable(),
device_type: z
.string()
.min(1, 'Device type cannot be empty')
.optional()
.nullable(),
project_id: z
.string()
.uuid('Project ID must be a valid UUID')
.optional(),
gateway_id: z
.string()
.uuid('Gateway ID must be a valid UUID')
.optional()
.nullable(),
status: z
.enum(['online', 'offline', 'maintenance', 'unknown'])
.optional(),
tts_device_id: z
.string()
.optional()
.nullable(),
app_key: z
.string()
.regex(/^[0-9A-Fa-f]{32}$/, 'AppKey must be a 32-character hexadecimal string')
.optional()
.nullable(),
join_eui: z
.string()
.regex(/^[0-9A-Fa-f]{16}$/, 'JoinEUI must be a 16-character hexadecimal string')
.optional()
.nullable(),
});
/**
* Type definitions derived from schemas
*/
export type CreateDeviceInput = z.infer<typeof createDeviceSchema>;
export type UpdateDeviceInput = z.infer<typeof updateDeviceSchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
details: errors,
});
return;
}
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares
*/
export const validateCreateDevice = validate(createDeviceSchema);
export const validateUpdateDevice = validate(updateDeviceSchema);

View File

@@ -0,0 +1,118 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Schema for creating a gateway
* - gateway_id: required, unique identifier
* - name: optional display name
* - project_id: required UUID, links to project
* - concentrator_id: optional UUID, links to concentrator
* - location: optional location description
* - status: optional status enum
* - tts_gateway_id: optional The Things Stack gateway ID
*/
export const createGatewaySchema = z.object({
gateway_id: z
.string({ required_error: 'Gateway ID is required' })
.min(1, 'Gateway ID cannot be empty'),
name: z
.string()
.min(1, 'Name cannot be empty')
.optional(),
project_id: z
.string({ required_error: 'Project ID is required' })
.uuid('Project ID must be a valid UUID'),
concentrator_id: z
.string()
.uuid('Concentrator ID must be a valid UUID')
.optional()
.nullable(),
location: z
.string()
.optional()
.nullable(),
status: z
.enum(['online', 'offline', 'maintenance', 'unknown'])
.optional()
.default('unknown'),
tts_gateway_id: z
.string()
.optional()
.nullable(),
});
/**
* Schema for updating a gateway
* All fields are optional
*/
export const updateGatewaySchema = z.object({
gateway_id: z
.string()
.min(1, 'Gateway ID cannot be empty')
.optional(),
name: z
.string()
.min(1, 'Name cannot be empty')
.optional()
.nullable(),
project_id: z
.string()
.uuid('Project ID must be a valid UUID')
.optional(),
concentrator_id: z
.string()
.uuid('Concentrator ID must be a valid UUID')
.optional()
.nullable(),
location: z
.string()
.optional()
.nullable(),
status: z
.enum(['online', 'offline', 'maintenance', 'unknown'])
.optional(),
tts_gateway_id: z
.string()
.optional()
.nullable(),
});
/**
* Type definitions derived from schemas
*/
export type CreateGatewayInput = z.infer<typeof createGatewaySchema>;
export type UpdateGatewayInput = z.infer<typeof updateGatewaySchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
details: errors,
});
return;
}
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares
*/
export const validateCreateGateway = validate(createGatewaySchema);
export const validateUpdateGateway = validate(updateGatewaySchema);

View File

@@ -0,0 +1,161 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Meter status enum values
*/
export const MeterStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
MAINTENANCE: 'MAINTENANCE',
FAULTY: 'FAULTY',
REPLACED: 'REPLACED',
} as const;
export type MeterStatusType = (typeof MeterStatus)[keyof typeof MeterStatus];
/**
* UUID validation regex
*/
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Meter type for LORA devices
*/
export const LoraType = {
LORA: 'LORA',
} as const;
/**
* Schema for creating a new meter
* - serial_number: required, unique identifier
* - name: required string
* - concentrator_id: required UUID (meters belong to concentrators)
* - location: optional string
* - type: optional, defaults to LORA
* - status: optional enum, defaults to ACTIVE
* - installation_date: optional date string
*/
export const createMeterSchema = z.object({
serial_number: z
.string({ required_error: 'Serial number is required' })
.min(1, 'Serial number cannot be empty')
.max(100, 'Serial number must be at most 100 characters'),
meter_id: z
.string()
.max(100, 'Meter ID must be at most 100 characters')
.optional()
.nullable()
.transform(val => (!val || val === '') ? null : val),
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(255, 'Name must be at most 255 characters'),
concentrator_id: z
.string({ required_error: 'Concentrator ID is required' })
.regex(uuidRegex, 'Concentrator ID must be a valid UUID'),
location: z
.string()
.max(500, 'Location must be at most 500 characters')
.optional()
.nullable(),
type: z
.string()
.max(50, 'Type must be at most 50 characters')
.default('LORA')
.optional(),
status: z
.enum([MeterStatus.ACTIVE, MeterStatus.INACTIVE, MeterStatus.MAINTENANCE, MeterStatus.FAULTY, MeterStatus.REPLACED])
.default(MeterStatus.ACTIVE)
.optional(),
installation_date: z
.string()
.datetime({ message: 'Installation date must be a valid ISO date string' })
.optional()
.nullable(),
});
/**
* Schema for updating a meter
* All fields are optional
*/
export const updateMeterSchema = z.object({
serial_number: z
.string()
.min(1, 'Serial number cannot be empty')
.max(100, 'Serial number must be at most 100 characters')
.optional(),
meter_id: z
.string()
.max(100, 'Meter ID must be at most 100 characters')
.optional()
.nullable()
.transform(val => (!val || val === '') ? null : val),
name: z
.string()
.min(1, 'Name cannot be empty')
.max(255, 'Name must be at most 255 characters')
.optional(),
concentrator_id: z
.string()
.regex(uuidRegex, 'Concentrator ID must be a valid UUID')
.optional(),
location: z
.string()
.max(500, 'Location must be at most 500 characters')
.optional()
.nullable(),
type: z
.string()
.max(50, 'Type must be at most 50 characters')
.optional(),
status: z
.enum([MeterStatus.ACTIVE, MeterStatus.INACTIVE, MeterStatus.MAINTENANCE, MeterStatus.FAULTY, MeterStatus.REPLACED])
.optional(),
installation_date: z
.string()
.datetime({ message: 'Installation date must be a valid ISO date string' })
.optional()
.nullable(),
});
/**
* Type definitions derived from schemas
*/
export type CreateMeterInput = z.infer<typeof createMeterSchema>;
export type UpdateMeterInput = z.infer<typeof updateMeterSchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
details: errors,
});
return;
}
// Replace body with validated and typed data
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares for meters
*/
export const validateCreateMeter = validate(createMeterSchema);
export const validateUpdateMeter = validate(updateMeterSchema);

View File

@@ -0,0 +1,118 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Project status enum values
*/
export const ProjectStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
COMPLETED: 'COMPLETED',
} as const;
export type ProjectStatusType = (typeof ProjectStatus)[keyof typeof ProjectStatus];
/**
* Schema for creating a new project
* - name: required, non-empty string
* - description: optional string
* - area_name: optional string
* - location: optional string
* - status: optional, defaults to ACTIVE
*/
export const createProjectSchema = z.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(255, 'Name must be at most 255 characters'),
description: z
.string()
.max(1000, 'Description must be at most 1000 characters')
.optional()
.nullable(),
area_name: z
.string()
.max(255, 'Area name must be at most 255 characters')
.optional()
.nullable(),
location: z
.string()
.max(500, 'Location must be at most 500 characters')
.optional()
.nullable(),
status: z
.enum([ProjectStatus.ACTIVE, ProjectStatus.INACTIVE, ProjectStatus.COMPLETED])
.default(ProjectStatus.ACTIVE)
.optional(),
});
/**
* Schema for updating a project
* All fields are optional
*/
export const updateProjectSchema = z.object({
name: z
.string()
.min(1, 'Name cannot be empty')
.max(255, 'Name must be at most 255 characters')
.optional(),
description: z
.string()
.max(1000, 'Description must be at most 1000 characters')
.optional()
.nullable(),
area_name: z
.string()
.max(255, 'Area name must be at most 255 characters')
.optional()
.nullable(),
location: z
.string()
.max(500, 'Location must be at most 500 characters')
.optional()
.nullable(),
status: z
.enum([ProjectStatus.ACTIVE, ProjectStatus.INACTIVE, ProjectStatus.COMPLETED])
.optional(),
});
/**
* Type definitions derived from schemas
*/
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
details: errors,
});
return;
}
// Replace body with validated and typed data
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares for projects
*/
export const validateCreateProject = validate(createProjectSchema);
export const validateUpdateProject = validate(updateProjectSchema);

View File

@@ -0,0 +1,141 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Valid role names enum
*/
export const RoleNameEnum = z.enum(['ADMIN', 'OPERATOR', 'VIEWER']);
/**
* Permissions schema (JSONB object)
* Defines what actions a role can perform
*/
export const permissionsSchema = z
.object({
// User management
users: z
.object({
create: z.boolean().optional(),
read: z.boolean().optional(),
update: z.boolean().optional(),
delete: z.boolean().optional(),
})
.optional(),
// Role management
roles: z
.object({
create: z.boolean().optional(),
read: z.boolean().optional(),
update: z.boolean().optional(),
delete: z.boolean().optional(),
})
.optional(),
// Project management
projects: z
.object({
create: z.boolean().optional(),
read: z.boolean().optional(),
update: z.boolean().optional(),
delete: z.boolean().optional(),
})
.optional(),
// Meter management
meters: z
.object({
create: z.boolean().optional(),
read: z.boolean().optional(),
update: z.boolean().optional(),
delete: z.boolean().optional(),
})
.optional(),
// Device management
devices: z
.object({
create: z.boolean().optional(),
read: z.boolean().optional(),
update: z.boolean().optional(),
delete: z.boolean().optional(),
})
.optional(),
// Readings
readings: z
.object({
create: z.boolean().optional(),
read: z.boolean().optional(),
export: z.boolean().optional(),
})
.optional(),
})
.passthrough(); // Allow additional properties for future extensibility
/**
* Schema for creating a new role
* - name: required, must be ADMIN, OPERATOR, or VIEWER
* - description: optional string
* - permissions: optional JSONB object
*/
export const createRoleSchema = z.object({
name: RoleNameEnum,
description: z
.string()
.max(500, 'Description cannot exceed 500 characters')
.nullable()
.optional(),
permissions: permissionsSchema.nullable().optional(),
});
/**
* Schema for updating a role
* All fields are optional
*/
export const updateRoleSchema = z.object({
name: RoleNameEnum.optional(),
description: z
.string()
.max(500, 'Description cannot exceed 500 characters')
.nullable()
.optional(),
permissions: permissionsSchema.nullable().optional(),
});
/**
* Type definitions derived from schemas
*/
export type CreateRoleInput = z.infer<typeof createRoleSchema>;
export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;
export type RoleName = z.infer<typeof RoleNameEnum>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
errors,
});
return;
}
// Replace body with validated and typed data
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares
*/
export const validateCreateRole = validate(createRoleSchema);
export const validateUpdateRole = validate(updateRoleSchema);

View File

@@ -0,0 +1,222 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* TTS Gateway IDs schema
*/
const gatewayIdsSchema = z.object({
gateway_id: z.string(),
eui: z.string().optional(),
});
/**
* TTS Application IDs schema
*/
const applicationIdsSchema = z.object({
application_id: z.string(),
});
/**
* TTS End Device IDs schema
* Common structure for identifying devices in TTS payloads
*/
const endDeviceIdsSchema = z.object({
device_id: z.string(),
dev_eui: z.string().regex(/^[0-9A-Fa-f]{16}$/, 'dev_eui must be 16 hex characters'),
join_eui: z.string().optional(),
application_ids: applicationIdsSchema,
});
/**
* TTS RX Metadata schema
* Contains information about the gateway that received the uplink
*/
const rxMetadataSchema = z.object({
gateway_ids: gatewayIdsSchema,
time: z.string().optional(),
timestamp: z.number().optional(),
rssi: z.number().optional(),
channel_rssi: z.number().optional(),
snr: z.number().optional(),
uplink_token: z.string().optional(),
channel_index: z.number().optional(),
gps_time: z.string().optional(),
received_at: z.string().optional(),
});
/**
* TTS Settings schema
* Contains LoRaWAN transmission settings
*/
const settingsSchema = z.object({
data_rate: z.object({
lora: z.object({
bandwidth: z.number().optional(),
spreading_factor: z.number().optional(),
coding_rate: z.string().optional(),
}).optional(),
}).optional(),
frequency: z.string().optional(),
timestamp: z.number().optional(),
time: z.string().optional(),
});
/**
* TTS Uplink Message schema
* The main uplink message structure containing payload and metadata
*/
const uplinkMessageSchema = z.object({
session_key_id: z.string().optional(),
f_port: z.number().min(1).max(255),
f_cnt: z.number().optional(),
frm_payload: z.string(), // Base64 encoded payload
decoded_payload: z.record(z.unknown()).optional(), // Decoded payload from TTS decoder
rx_metadata: z.array(rxMetadataSchema).optional(),
settings: settingsSchema.optional(),
received_at: z.string().optional(),
consumed_airtime: z.string().optional(),
network_ids: z.object({
net_id: z.string().optional(),
tenant_id: z.string().optional(),
cluster_id: z.string().optional(),
cluster_address: z.string().optional(),
}).optional(),
});
/**
* Schema for TTS uplink webhook payload
* This is the main payload structure received when a device sends an uplink
*/
export const uplinkSchema = z.object({
end_device_ids: endDeviceIdsSchema,
correlation_ids: z.array(z.string()).optional(),
received_at: z.string(),
uplink_message: uplinkMessageSchema,
simulated: z.boolean().optional(),
});
/**
* TTS Join Accept schema
* Contains information about the join session
*/
const joinAcceptSchema = z.object({
session_key_id: z.string().optional(),
received_at: z.string().optional(),
});
/**
* Schema for TTS join webhook payload
* Received when a device successfully joins the network
*/
export const joinSchema = z.object({
end_device_ids: endDeviceIdsSchema,
correlation_ids: z.array(z.string()).optional(),
received_at: z.string(),
join_accept: joinAcceptSchema.optional(),
});
/**
* TTS Downlink schema
* Contains information about a queued downlink message
*/
const downlinkSchema = z.object({
f_port: z.number().optional(),
f_cnt: z.number().optional(),
frm_payload: z.string().optional(),
decoded_payload: z.record(z.unknown()).optional(),
confirmed: z.boolean().optional(),
priority: z.string().optional(),
correlation_ids: z.array(z.string()).optional(),
});
/**
* Schema for TTS downlink acknowledgment webhook payload
* Received when a confirmed downlink is acknowledged by the device
*/
export const downlinkAckSchema = z.object({
end_device_ids: endDeviceIdsSchema,
correlation_ids: z.array(z.string()).optional(),
received_at: z.string(),
downlink_ack: z.object({
session_key_id: z.string().optional(),
f_port: z.number().optional(),
f_cnt: z.number().optional(),
frm_payload: z.string().optional(),
decoded_payload: z.record(z.unknown()).optional(),
confirmed: z.boolean().optional(),
priority: z.string().optional(),
correlation_ids: z.array(z.string()).optional(),
}).optional(),
downlink_sent: z.object({
session_key_id: z.string().optional(),
f_port: z.number().optional(),
f_cnt: z.number().optional(),
frm_payload: z.string().optional(),
decoded_payload: z.record(z.unknown()).optional(),
confirmed: z.boolean().optional(),
priority: z.string().optional(),
correlation_ids: z.array(z.string()).optional(),
}).optional(),
downlink_failed: z.object({
downlink: downlinkSchema.optional(),
error: z.object({
namespace: z.string().optional(),
name: z.string().optional(),
message_format: z.string().optional(),
code: z.number().optional(),
}).optional(),
}).optional(),
downlink_queued: z.object({
session_key_id: z.string().optional(),
f_port: z.number().optional(),
f_cnt: z.number().optional(),
frm_payload: z.string().optional(),
decoded_payload: z.record(z.unknown()).optional(),
confirmed: z.boolean().optional(),
priority: z.string().optional(),
correlation_ids: z.array(z.string()).optional(),
}).optional(),
});
/**
* Type definitions derived from schemas
*/
export type TtsUplinkPayload = z.infer<typeof uplinkSchema>;
export type TtsJoinPayload = z.infer<typeof joinSchema>;
export type TtsDownlinkAckPayload = z.infer<typeof downlinkAckSchema>;
/**
* Generic validation middleware factory for TTS webhooks
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validateTtsPayload<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Invalid TTS webhook payload',
details: errors,
});
return;
}
// Replace body with validated and typed data
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares for TTS webhooks
*/
export const validateUplink = validateTtsPayload(uplinkSchema);
export const validateJoin = validateTtsPayload(joinSchema);
export const validateDownlinkAck = validateTtsPayload(downlinkAckSchema);

View File

@@ -0,0 +1,119 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
/**
* Schema for creating a new user
* - email: required, must be valid email format
* - password: required, minimum 8 characters
* - name: required (first_name + last_name combined or separate)
* - role_id: required, must be valid UUID
* - is_active: optional, defaults to true
*/
export const createUserSchema = z.object({
email: z
.string({ required_error: 'Email is required' })
.email('Invalid email format')
.transform((val) => val.toLowerCase().trim()),
password: z
.string({ required_error: 'Password is required' })
.min(8, 'Password must be at least 8 characters'),
first_name: z
.string({ required_error: 'First name is required' })
.min(1, 'First name cannot be empty')
.max(100, 'First name cannot exceed 100 characters'),
last_name: z
.string({ required_error: 'Last name is required' })
.min(1, 'Last name cannot be empty')
.max(100, 'Last name cannot exceed 100 characters'),
role_id: z
.number({ required_error: 'Role ID is required' })
.int('Role ID must be an integer')
.positive('Role ID must be a positive number'),
is_active: z.boolean().default(true),
});
/**
* Schema for updating a user
* All fields are optional
* Password has different rules (not allowed in regular update)
*/
export const updateUserSchema = z.object({
email: z
.string()
.email('Invalid email format')
.transform((val) => val.toLowerCase().trim())
.optional(),
first_name: z
.string()
.min(1, 'First name cannot be empty')
.max(100, 'First name cannot exceed 100 characters')
.optional(),
last_name: z
.string()
.min(1, 'Last name cannot be empty')
.max(100, 'Last name cannot exceed 100 characters')
.optional(),
role_id: z
.number()
.int('Role ID must be an integer')
.positive('Role ID must be a positive number')
.optional(),
is_active: z.boolean().optional(),
});
/**
* Schema for changing password
* - current_password: required
* - new_password: required, minimum 8 characters
*/
export const changePasswordSchema = z.object({
current_password: z
.string({ required_error: 'Current password is required' })
.min(1, 'Current password cannot be empty'),
new_password: z
.string({ required_error: 'New password is required' })
.min(8, 'New password must be at least 8 characters'),
});
/**
* Type definitions derived from schemas
*/
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
/**
* Generic validation middleware factory
* Creates a middleware that validates request body against a Zod schema
* @param schema - Zod schema to validate against
*/
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
success: false,
error: 'Validation failed',
errors,
});
return;
}
// Replace body with validated and typed data
req.body = result.data;
next();
};
}
/**
* Pre-configured validation middlewares
*/
export const validateCreateUser = validate(createUserSchema);
export const validateUpdateUser = validate(updateUserSchema);
export const validateChangePassword = validate(changePasswordSchema);

44
water-api/tsconfig.json Normal file
View File

@@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"],
"@config/*": ["config/*"],
"@middleware/*": ["middleware/*"],
"@models/*": ["models/*"],
"@services/*": ["services/*"],
"@controllers/*": ["controllers/*"],
"@routes/*": ["routes/*"],
"@validators/*": ["validators/*"],
"@utils/*": ["utils/*"],
"@types/*": ["types/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}