Compare commits
3 Commits
2b5735d78d
...
6c7d448b2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c7d448b2f | ||
|
|
ab97987c6a | ||
|
|
c81a18987f |
171
CAMBIOS_SESION.md
Normal file
171
CAMBIOS_SESION.md
Normal 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
249
ESTADO_ACTUAL.md
Normal 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
|
||||||
116
src/App.tsx
116
src/App.tsx
@@ -8,6 +8,7 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage";
|
|||||||
import ProjectsPage from "./pages/projects/ProjectsPage";
|
import ProjectsPage from "./pages/projects/ProjectsPage";
|
||||||
import UsersPage from "./pages/UsersPage";
|
import UsersPage from "./pages/UsersPage";
|
||||||
import RolesPage from "./pages/RolesPage";
|
import RolesPage from "./pages/RolesPage";
|
||||||
|
import ConsumptionPage from "./pages/consumption/ConsumptionPage";
|
||||||
import ProfileModal from "./components/layout/common/ProfileModal";
|
import ProfileModal from "./components/layout/common/ProfileModal";
|
||||||
import { updateMyProfile } from "./api/me";
|
import { updateMyProfile } from "./api/me";
|
||||||
|
|
||||||
@@ -18,7 +19,15 @@ import SettingsModal, {
|
|||||||
|
|
||||||
import LoginPage from "./pages/LoginPage";
|
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 ConfirmModal from "./components/layout/common/ConfirmModal";
|
||||||
import Watermark from "./components/layout/common/Watermark";
|
import Watermark from "./components/layout/common/Watermark";
|
||||||
|
|
||||||
@@ -27,28 +36,59 @@ export type Page =
|
|||||||
| "projects"
|
| "projects"
|
||||||
| "meters"
|
| "meters"
|
||||||
| "concentrators"
|
| "concentrators"
|
||||||
|
| "consumption"
|
||||||
| "users"
|
| "users"
|
||||||
| "roles";
|
| "roles";
|
||||||
|
|
||||||
const AUTH_KEY = "grh_auth";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isAuth, setIsAuth] = useState<boolean>(() => {
|
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||||
return Boolean(localStorage.getItem(AUTH_KEY));
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
});
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
|
||||||
const handleLogin = (payload?: { token?: string }) => {
|
// Check authentication on mount
|
||||||
localStorage.setItem(
|
useEffect(() => {
|
||||||
AUTH_KEY,
|
const checkAuth = async () => {
|
||||||
JSON.stringify({ token: payload?.token ?? "demo", ts: Date.now() })
|
if (isAuthenticated()) {
|
||||||
);
|
try {
|
||||||
setIsAuth(true);
|
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 = () => {
|
const handleLogout = async () => {
|
||||||
localStorage.removeItem(AUTH_KEY);
|
try {
|
||||||
|
await authLogout();
|
||||||
|
} catch {
|
||||||
|
// Ignore logout errors
|
||||||
|
}
|
||||||
|
clearAuth();
|
||||||
|
setUser(null);
|
||||||
setIsAuth(false);
|
setIsAuth(false);
|
||||||
// opcional: reset de navegación
|
// Reset navigation
|
||||||
setPage("home");
|
setPage("home");
|
||||||
setSubPage("default");
|
setSubPage("default");
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
@@ -65,13 +105,6 @@ export default function App() {
|
|||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [savingProfile, setSavingProfile] = 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 [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
|
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
|
||||||
|
|
||||||
@@ -84,7 +117,7 @@ export default function App() {
|
|||||||
const handleUploadAvatar = async (file: File) => {
|
const handleUploadAvatar = async (file: File) => {
|
||||||
const base64 = await fileToBase64(file);
|
const base64 = await fileToBase64(file);
|
||||||
localStorage.setItem("mock_avatar", base64);
|
localStorage.setItem("mock_avatar", base64);
|
||||||
setUser((prev) => ({ ...prev, avatarUrl: base64 }));
|
setUser((prev) => prev ? { ...prev, avatar_url: base64 } : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
function fileToBase64(file: File) {
|
function fileToBase64(file: File) {
|
||||||
@@ -101,18 +134,17 @@ export default function App() {
|
|||||||
email: string;
|
email: string;
|
||||||
organismName?: string;
|
organismName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!user) return;
|
||||||
setSavingProfile(true);
|
setSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
const updated = await updateMyProfile(next);
|
const updated = await updateMyProfile(next);
|
||||||
|
|
||||||
setUser((prev) => ({
|
setUser((prev) => prev ? ({
|
||||||
...prev,
|
...prev,
|
||||||
name: updated.name ?? next.name ?? prev.name,
|
name: updated.name ?? next.name ?? prev.name,
|
||||||
email: updated.email ?? next.email ?? prev.email,
|
email: updated.email ?? next.email ?? prev.email,
|
||||||
avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
|
avatar_url: updated.avatarUrl ?? prev.avatar_url,
|
||||||
organismName:
|
}) : null);
|
||||||
updated.organismName ?? next.organismName ?? prev.organismName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setProfileOpen(false);
|
setProfileOpen(false);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,6 +173,8 @@ export default function App() {
|
|||||||
return <MetersPage selectedProject={selectedProject} />;
|
return <MetersPage selectedProject={selectedProject} />;
|
||||||
case "concentrators":
|
case "concentrators":
|
||||||
return <ConcentratorsPage />;
|
return <ConcentratorsPage />;
|
||||||
|
case "consumption":
|
||||||
|
return <ConsumptionPage />;
|
||||||
case "users":
|
case "users":
|
||||||
return <UsersPage />;
|
return <UsersPage />;
|
||||||
case "roles":
|
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) {
|
if (!isAuth) {
|
||||||
return <LoginPage onSuccess={handleLogin} />;
|
return <LoginPage onSuccess={handleLogin} />;
|
||||||
}
|
}
|
||||||
@@ -186,11 +229,10 @@ export default function App() {
|
|||||||
page={page}
|
page={page}
|
||||||
subPage={subPage}
|
subPage={subPage}
|
||||||
setSubPage={setSubPage}
|
setSubPage={setSubPage}
|
||||||
userName={user.name}
|
userName={user?.name ?? "Usuario"}
|
||||||
userEmail={user.email}
|
userEmail={user?.email ?? ""}
|
||||||
avatarUrl={user.avatarUrl}
|
avatarUrl={user?.avatar_url ?? null}
|
||||||
onOpenProfile={() => setProfileOpen(true)}
|
onOpenProfile={() => setProfileOpen(true)}
|
||||||
// ✅ en vez de cerrar, abrimos confirm modal
|
|
||||||
onRequestLogout={() => setLogoutOpen(true)}
|
onRequestLogout={() => setLogoutOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,11 +254,11 @@ export default function App() {
|
|||||||
<ProfileModal
|
<ProfileModal
|
||||||
open={profileOpen}
|
open={profileOpen}
|
||||||
loading={savingProfile}
|
loading={savingProfile}
|
||||||
avatarUrl={user.avatarUrl}
|
avatarUrl={user?.avatar_url ?? null}
|
||||||
initial={{
|
initial={{
|
||||||
name: user.name,
|
name: user?.name ?? "",
|
||||||
email: user.email,
|
email: user?.email ?? "",
|
||||||
organismName: user.organismName,
|
organismName: "",
|
||||||
}}
|
}}
|
||||||
onClose={() => setProfileOpen(false)}
|
onClose={() => setProfileOpen(false)}
|
||||||
onSave={handleSaveProfile}
|
onSave={handleSaveProfile}
|
||||||
@@ -236,7 +278,7 @@ export default function App() {
|
|||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
handleLogout();
|
await handleLogout();
|
||||||
setLogoutOpen(false);
|
setLogoutOpen(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoggingOut(false);
|
setLoggingOut(false);
|
||||||
|
|||||||
331
src/api/auth.ts
Normal file
331
src/api/auth.ts
Normal 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
393
src/api/client.ts
Normal 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;
|
||||||
@@ -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`;
|
* Concentrators API
|
||||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
|
* Handles all concentrator-related API operations using the backend API client
|
||||||
|
*/
|
||||||
|
|
||||||
const getAuthHeaders = () => ({
|
import { apiClient } from './client';
|
||||||
Authorization: `Bearer ${API_TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface ConcentratorRecord {
|
// Helper to convert snake_case to camelCase
|
||||||
id: string;
|
function snakeToCamel(str: string): string {
|
||||||
fields: {
|
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
"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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConcentratorsResponse {
|
// Transform object keys from snake_case to camelCase
|
||||||
records: ConcentratorRecord[];
|
function transformKeys<T>(obj: Record<string, unknown>): T {
|
||||||
next?: string;
|
const transformed: Record<string, unknown> = {};
|
||||||
prev?: string;
|
for (const key in obj) {
|
||||||
nestedNext?: string;
|
const camelKey = snakeToCamel(key);
|
||||||
nestedPrev?: string;
|
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 {
|
export interface Concentrator {
|
||||||
id: string;
|
id: string;
|
||||||
"Area Name": string;
|
serialNumber: string;
|
||||||
"Device S/N": string;
|
name: string;
|
||||||
"Device Name": string;
|
projectId: string;
|
||||||
"Device Time": string;
|
location: string | null;
|
||||||
"Device Status": string;
|
type: ConcentratorType;
|
||||||
"Operator": string;
|
status: string;
|
||||||
"Installed Time": string;
|
ipAddress: string | null;
|
||||||
"Communication Time": string;
|
firmwareVersion: string | null;
|
||||||
"Instruction Manual": string;
|
lastCommunication: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchConcentrators = async (): Promise<Concentrator[]> => {
|
/**
|
||||||
try {
|
* Input data for creating or updating a concentrator
|
||||||
const url = new URL(CONCENTRATORS_API_URL);
|
*/
|
||||||
url.searchParams.set('viewId', 'vw93mj98ylyxratm');
|
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(), {
|
// Handle paginated response
|
||||||
method: "GET",
|
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
||||||
headers: getAuthHeaders(),
|
return transformArray<Concentrator>(response.data);
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch concentrators");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: ConcentratorsResponse = await response.json();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export const createConcentrator = async (
|
// Handle array response (fallback)
|
||||||
concentratorData: Omit<Concentrator, "id">
|
return transformArray<Concentrator>(response as Record<string, unknown>[]);
|
||||||
): 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}`);
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
/**
|
||||||
const createdRecord = data.records?.[0];
|
* 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,
|
||||||
|
};
|
||||||
|
const response = await apiClient.post<Record<string, unknown>>('/api/concentrators', backendData);
|
||||||
|
return transformKeys<Concentrator>(response);
|
||||||
|
}
|
||||||
|
|
||||||
if (!createdRecord) {
|
/**
|
||||||
throw new Error("Invalid response format: no record returned");
|
* 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;
|
||||||
|
|
||||||
return {
|
const response = await apiClient.patch<Record<string, unknown>>(`/api/concentrators/${id}`, backendData);
|
||||||
id: createdRecord.id,
|
return transformKeys<Concentrator>(response);
|
||||||
"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"],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating concentrator:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateConcentrator = async (
|
/**
|
||||||
id: string,
|
* Delete a concentrator
|
||||||
concentratorData: Omit<Concentrator, "id">
|
* @param id - The concentrator ID
|
||||||
): Promise<Concentrator> => {
|
* @returns Promise resolving when the concentrator is deleted
|
||||||
try {
|
*/
|
||||||
const response = await fetch(CONCENTRATORS_API_URL, {
|
export async function deleteConcentrator(id: string): Promise<void> {
|
||||||
method: "PATCH",
|
return apiClient.delete<void>(`/api/concentrators/${id}`);
|
||||||
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"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
110
src/api/me.ts
110
src/api/me.ts
@@ -1,45 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* User Profile API
|
||||||
|
* Handles all user profile-related API operations using the backend API client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User entity from the backend
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
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 }> {
|
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
|
||||||
const form = new FormData();
|
// For file uploads, we need to use FormData and handle it differently
|
||||||
form.append("avatar", file);
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
const res = await fetch("/api/me/avatar", {
|
const token = localStorage.getItem('grh_access_token');
|
||||||
method: "POST",
|
|
||||||
body: form,
|
|
||||||
// NO pongas Content-Type; el browser lo agrega con boundary
|
|
||||||
headers: {
|
|
||||||
// Si usas token:
|
|
||||||
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const response = await fetch('/api/me/avatar', {
|
||||||
const text = await res.text().catch(() => "");
|
method: 'POST',
|
||||||
throw new Error(`Upload avatar failed: ${res.status} ${text}`);
|
headers: {
|
||||||
}
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
if (!response.ok) {
|
||||||
if (!data?.avatarUrl) throw new Error("Respuesta sin avatarUrl");
|
const errorText = await response.text().catch(() => '');
|
||||||
return { avatarUrl: data.avatarUrl };
|
throw new Error(`Upload avatar failed: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMyProfile(input: {
|
const data = await response.json();
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
}): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> {
|
|
||||||
const res = await fetch("/api/me", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!data?.avatarUrl && !data?.data?.avatarUrl) {
|
||||||
const text = await res.text().catch(() => "");
|
throw new Error('Response missing avatarUrl');
|
||||||
throw new Error(`Update profile failed: ${res.status} ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`;
|
* Meters API
|
||||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
|
* Handles all meter-related API operations using the backend API client
|
||||||
|
*/
|
||||||
|
|
||||||
const getAuthHeaders = () => ({
|
import { apiClient } from './client';
|
||||||
Authorization: `Bearer ${API_TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface MeterRecord {
|
// 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 entity from the backend
|
||||||
|
* Meters belong to Concentrators (LORA protocol)
|
||||||
|
*/
|
||||||
|
export interface Meter {
|
||||||
id: string;
|
id: string;
|
||||||
fields: {
|
serialNumber: string;
|
||||||
CreatedAt: string;
|
meterId: string | null;
|
||||||
UpdatedAt: string;
|
name: string;
|
||||||
"Area Name": string;
|
concentratorId: string;
|
||||||
"Account Number": string | null;
|
location: string | null;
|
||||||
"User Name": string | null;
|
type: string;
|
||||||
"User Address": string | null;
|
status: string;
|
||||||
"Meter S/N": string;
|
lastReadingValue: number | null;
|
||||||
"Meter Name": string;
|
lastReadingAt: string | null;
|
||||||
"Meter Status": string;
|
installationDate: string | null;
|
||||||
"Protocol Type": string;
|
createdAt: string;
|
||||||
"Price No.": string | null;
|
updatedAt: string;
|
||||||
"Price Name": string | null;
|
// From joins
|
||||||
"DMA Partition": string | null;
|
concentratorName?: string;
|
||||||
"Supply Types": string;
|
concentratorSerial?: string;
|
||||||
"Device ID": string;
|
projectId?: string;
|
||||||
"Device Name": string;
|
projectName?: string;
|
||||||
"Device Type": string;
|
}
|
||||||
"Usage Analysis Type": string;
|
|
||||||
"installed Time": string;
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meter reading entity
|
||||||
|
*/
|
||||||
|
export interface MeterReading {
|
||||||
|
id: string;
|
||||||
|
meterId: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
readingType: string;
|
||||||
|
readAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
|
||||||
|
if (filters?.projectId) params.project_id = filters.projectId;
|
||||||
|
|
||||||
|
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: unknown }>('/api/meters', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle paginated response
|
||||||
|
if (response && typeof response === 'object' && 'data' in response) {
|
||||||
|
return transformArray<Meter>(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-paginated response
|
||||||
|
return transformArray<Meter>(response as unknown as Record<string, unknown>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
const response = await apiClient.post<Record<string, unknown>>('/api/meters', backendData);
|
||||||
|
return transformKeys<Meter>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetersResponse {
|
/**
|
||||||
records: MeterRecord[];
|
* Bulk upload meters from Excel file
|
||||||
next?: string;
|
* @param file - Excel file to upload
|
||||||
prev?: string;
|
* @returns Promise resolving to upload result
|
||||||
nestedNext?: string;
|
*/
|
||||||
nestedPrev?: string;
|
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) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Error en la carga masiva');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Meter {
|
/**
|
||||||
id: string;
|
* Download meter template Excel file
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
export async function downloadMeterTemplate(): Promise<void> {
|
||||||
areaName: string;
|
const token = localStorage.getItem('grh_access_token');
|
||||||
accountNumber: string | null;
|
|
||||||
userName: string | null;
|
if (!token) {
|
||||||
userAddress: string | null;
|
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
|
||||||
meterSerialNumber: string;
|
}
|
||||||
meterName: string;
|
|
||||||
meterStatus: string;
|
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, {
|
||||||
protocolType: string;
|
method: 'GET',
|
||||||
priceNo: string | null;
|
headers: {
|
||||||
priceName: string | null;
|
'Authorization': `Bearer ${token}`,
|
||||||
dmaPartition: string | null;
|
},
|
||||||
supplyTypes: string;
|
});
|
||||||
deviceId: string;
|
|
||||||
deviceName: string;
|
if (!response.ok) {
|
||||||
deviceType: string;
|
// Try to get error message from response
|
||||||
usageAnalysisType: string;
|
const contentType = response.headers.get('content-type');
|
||||||
installedTime: string;
|
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_medidores.xlsx';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: "GET",
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating meter:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating meter:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 meter: ${response.status} ${response.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting meter:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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`;
|
* Projects API
|
||||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
|
* Handles all project-related API operations using the backend API client
|
||||||
|
*/
|
||||||
|
|
||||||
export const getAuthHeaders = () => ({
|
import { apiClient } from './client';
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${API_TOKEN}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface ProjectRecord {
|
// Helper to convert snake_case to camelCase
|
||||||
id: number;
|
function snakeToCamel(str: string): string {
|
||||||
fields: {
|
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
"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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectsResponse {
|
// Transform object keys from snake_case to camelCase
|
||||||
records: ProjectRecord[];
|
function transformKeys<T>(obj: Record<string, unknown>): T {
|
||||||
next?: string;
|
const transformed: Record<string, unknown> = {};
|
||||||
prev?: string;
|
for (const key in obj) {
|
||||||
nestedNext?: string;
|
const camelKey = snakeToCamel(key);
|
||||||
nestedPrev?: string;
|
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 {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
areaName: string;
|
areaName: string;
|
||||||
deviceSN: string;
|
location: string | null;
|
||||||
deviceName: string;
|
status: string;
|
||||||
deviceType: string;
|
createdBy: string;
|
||||||
deviceStatus: "ACTIVE" | "INACTIVE";
|
createdAt: string;
|
||||||
operator: string;
|
updatedAt: string;
|
||||||
installedTime: string;
|
|
||||||
communicationTime: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchProjectNames = async (): Promise<string[]> => {
|
/**
|
||||||
try {
|
* Input data for creating or updating a project
|
||||||
const response = await fetch(PROJECTS_API_URL, {
|
*/
|
||||||
method: "GET",
|
export interface ProjectInput {
|
||||||
headers: getAuthHeaders(),
|
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');
|
||||||
|
|
||||||
const data: ProjectsResponse = await response.json();
|
// Handle paginated response
|
||||||
|
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
||||||
if (!data.records || data.records.length === 0) {
|
return transformArray<Project>(response.data);
|
||||||
console.warn("No project records found from API");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
// Handle array response (fallback)
|
||||||
|
return transformArray<Project>(response as Record<string, unknown>[]);
|
||||||
|
}
|
||||||
|
|
||||||
const createdRecord = data.records?.[0];
|
/**
|
||||||
if (!createdRecord) {
|
* Fetch a single project by ID
|
||||||
throw new Error("Invalid response format: no record returned");
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
/**
|
||||||
id: createdRecord.id.toString(),
|
* Create a new project
|
||||||
areaName: createdRecord.fields["Area Name"] ?? projectData.areaName,
|
* @param data - The project data
|
||||||
deviceSN: createdRecord.fields["Device S/N"] ?? projectData.deviceSN,
|
* @returns Promise resolving to the created project
|
||||||
deviceName: createdRecord.fields["Device Name"] ?? projectData.deviceName,
|
*/
|
||||||
deviceType: createdRecord.fields["Device Type"] ?? projectData.deviceType,
|
export async function createProject(data: ProjectInput): Promise<Project> {
|
||||||
deviceStatus:
|
const backendData = {
|
||||||
createdRecord.fields["Device Status"] === "Installed"
|
name: data.name,
|
||||||
? "ACTIVE"
|
description: data.description,
|
||||||
: "INACTIVE",
|
area_name: data.areaName,
|
||||||
operator: createdRecord.fields["Operator"] ?? projectData.operator,
|
location: data.location,
|
||||||
installedTime:
|
status: data.status,
|
||||||
createdRecord.fields["Installed Time"] ?? projectData.installedTime,
|
|
||||||
communicationTime:
|
|
||||||
createdRecord.fields["Communication time"] ??
|
|
||||||
projectData.communicationTime,
|
|
||||||
};
|
};
|
||||||
};
|
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
|
||||||
|
return transformKeys<Project>(response);
|
||||||
|
}
|
||||||
|
|
||||||
export const updateProject = async (
|
/**
|
||||||
id: string,
|
* Update an existing project
|
||||||
projectData: Omit<Project, "id">
|
* @param id - The project ID
|
||||||
): Promise<Project> => {
|
* @param data - The updated project data
|
||||||
const response = await fetch(PROJECTS_API_URL, {
|
* @returns Promise resolving to the updated project
|
||||||
method: "PATCH",
|
*/
|
||||||
headers: getAuthHeaders(),
|
export async function updateProject(id: string, data: Partial<ProjectInput>): Promise<Project> {
|
||||||
body: JSON.stringify({
|
const backendData: Record<string, unknown> = {};
|
||||||
id: parseInt(id),
|
if (data.name !== undefined) backendData.name = data.name;
|
||||||
fields: {
|
if (data.description !== undefined) backendData.description = data.description;
|
||||||
"Area Name": projectData.areaName,
|
if (data.areaName !== undefined) backendData.area_name = data.areaName;
|
||||||
"Device S/N": projectData.deviceSN,
|
if (data.location !== undefined) backendData.location = data.location;
|
||||||
"Device Name": projectData.deviceName,
|
if (data.status !== undefined) backendData.status = data.status;
|
||||||
"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) {
|
const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData);
|
||||||
if (response.status === 400) {
|
return transformKeys<Project>(response);
|
||||||
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 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) {
|
* Fetch unique area names from all projects
|
||||||
throw new Error("Invalid response format: no record returned");
|
* @returns Promise resolving to an array of unique area names
|
||||||
}
|
*/
|
||||||
|
export async function fetchProjectNames(): Promise<string[]> {
|
||||||
return {
|
const projects = await fetchProjects();
|
||||||
id: updatedRecord.id.toString(),
|
const areaNames = [...new Set(projects.map(p => p.areaName).filter(Boolean))];
|
||||||
areaName: updatedRecord.fields["Area Name"] ?? projectData.areaName,
|
return areaNames;
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
271
src/api/readings.ts
Normal file
271
src/api/readings.ts
Normal 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
133
src/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
Settings,
|
Settings,
|
||||||
WaterDrop,
|
|
||||||
ExpandMore,
|
ExpandMore,
|
||||||
ExpandLess,
|
ExpandLess,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -106,6 +105,15 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
|||||||
Meters
|
Meters
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function Home({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredProjects = useMemo(
|
const filteredProjects = useMemo(
|
||||||
() => [...new Set(filteredMeters.map((m) => m.areaName))],
|
() => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[],
|
||||||
[filteredMeters]
|
[filteredMeters]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export default function Home({
|
|||||||
() =>
|
() =>
|
||||||
filteredProjects.map((projectName) => ({
|
filteredProjects.map((projectName) => ({
|
||||||
name: projectName,
|
name: projectName,
|
||||||
meterCount: filteredMeters.filter((m) => m.areaName === projectName)
|
meterCount: filteredMeters.filter((m) => m.projectName === projectName)
|
||||||
.length,
|
.length,
|
||||||
})),
|
})),
|
||||||
[filteredProjects, filteredMeters]
|
[filteredProjects, filteredMeters]
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 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 = {
|
type LoginPageProps = {
|
||||||
onSuccess: (payload?: { token?: string }) => void;
|
onSuccess: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage({ onSuccess }: LoginPageProps) {
|
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 [showPass, setShowPass] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [serverError, setServerError] = useState("");
|
const [serverError, setServerError] = useState("");
|
||||||
const [notRobot, setNotRobot] = useState(false);
|
|
||||||
|
|
||||||
const errors = useMemo(() => {
|
const errors = useMemo(() => {
|
||||||
const e: Partial<Record<keyof Form | "robot", string>> = {};
|
const e: Partial<Record<keyof Form, string>> = {};
|
||||||
if (!form.usuario.trim()) e.usuario = "El usuario es obligatorio.";
|
if (!form.email.trim()) e.email = "El correo es obligatorio.";
|
||||||
if (!form.contrasena) e.contrasena = "La contraseña es obligatoria.";
|
if (!form.password) e.password = "La contraseña es obligatoria.";
|
||||||
if (!notRobot) e.robot = "Confirma que no eres un robot.";
|
|
||||||
return e;
|
return e;
|
||||||
}, [form.usuario, form.contrasena, notRobot]);
|
}, [form.email, form.password]);
|
||||||
|
|
||||||
const canSubmit = Object.keys(errors).length === 0 && !loading;
|
const canSubmit = Object.keys(errors).length === 0 && !loading;
|
||||||
|
|
||||||
@@ -30,12 +29,13 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
setServerError("");
|
setServerError("");
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
await login({ email: form.email, password: form.password });
|
||||||
await new Promise((r) => setTimeout(r, 700));
|
// Tokens are stored by the auth module
|
||||||
onSuccess({ token: "demo" });
|
onSuccess();
|
||||||
} catch {
|
} catch (err) {
|
||||||
setServerError("No se pudo iniciar sesión. Verifica tus datos.");
|
setServerError(err instanceof Error ? err.message : "Error de autenticación");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -111,27 +111,28 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Usuario */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700">
|
<label className="block text-sm font-medium text-slate-700">
|
||||||
Usuario
|
Correo electrónico
|
||||||
</label>
|
</label>
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<input
|
<input
|
||||||
value={form.usuario}
|
type="email"
|
||||||
|
value={form.email}
|
||||||
onChange={(e) =>
|
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"
|
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"
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
size={18}
|
size={18}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.usuario && (
|
{errors.email && (
|
||||||
<p className="mt-1 text-xs text-red-600">
|
<p className="mt-1 text-xs text-red-600">
|
||||||
{errors.usuario}
|
{errors.email}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,9 +144,9 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<input
|
<input
|
||||||
value={form.contrasena}
|
value={form.password}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((s) => ({ ...s, contrasena: e.target.value }))
|
setForm((s) => ({ ...s, password: e.target.value }))
|
||||||
}
|
}
|
||||||
type={showPass ? "text" : "password"}
|
type={showPass ? "text" : "password"}
|
||||||
className="w-full border-b border-slate-300 py-2 pr-16 outline-none focus:border-slate-600"
|
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}
|
size={18}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.contrasena && (
|
{errors.password && (
|
||||||
<p className="mt-1 text-xs text-red-600">
|
<p className="mt-1 text-xs text-red-600">
|
||||||
{errors.contrasena}
|
{errors.password}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Botón */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,381 +1,191 @@
|
|||||||
// src/pages/concentrators/ConcentratorsModal.tsx
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { Concentrator } from "../../api/concentrators";
|
import { useEffect, useState } from "react";
|
||||||
import type { GatewayData } from "./ConcentratorsPage";
|
import type { ConcentratorInput } from "../../api/concentrators";
|
||||||
|
import { fetchProjects, type Project } from "../../api/projects";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editingSerial: string | null;
|
editingId: string | null;
|
||||||
|
form: ConcentratorInput;
|
||||||
form: Omit<Concentrator, "id">;
|
setForm: React.Dispatch<React.SetStateAction<ConcentratorInput>>;
|
||||||
setForm: React.Dispatch<React.SetStateAction<Omit<Concentrator, "id">>>;
|
|
||||||
|
|
||||||
gatewayForm: GatewayData;
|
|
||||||
setGatewayForm: React.Dispatch<React.SetStateAction<GatewayData>>;
|
|
||||||
|
|
||||||
errors: Record<string, boolean>;
|
errors: Record<string, boolean>;
|
||||||
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
allProjects: string[];
|
||||||
toDatetimeLocalValue: (value?: string) => string;
|
|
||||||
fromDatetimeLocalValue: (value: string) => string;
|
|
||||||
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void | Promise<void>;
|
onSave: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConcentratorsModal({
|
export default function ConcentratorsModal({
|
||||||
editingSerial,
|
editingId,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
gatewayForm,
|
|
||||||
setGatewayForm,
|
|
||||||
errors,
|
errors,
|
||||||
setErrors,
|
setErrors,
|
||||||
toDatetimeLocalValue,
|
|
||||||
fromDatetimeLocalValue,
|
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
<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>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
|
||||||
{/* FORM */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||||
Concentrator Information
|
Información del Concentrador
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
|
||||||
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>
|
|
||||||
<input
|
<input
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
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 *"
|
placeholder="Número de serie"
|
||||||
value={form["Device S/N"]}
|
value={form.serialNumber}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, "Device S/N": e.target.value });
|
setForm({ ...form, serialNumber: e.target.value });
|
||||||
if (errors["Device S/N"])
|
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
|
||||||
setErrors({ ...errors, "Device S/N": false });
|
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{errors["Device S/N"] && (
|
{errors["serialNumber"] && (
|
||||||
<p className="text-red-500 text-xs mt-1">
|
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
|
||||||
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["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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<select
|
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="ACTIVE">ACTIVE</option>
|
|
||||||
<option value="INACTIVE">INACTIVE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<input
|
<input
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
errors["Operator"] ? "border-red-500" : ""
|
errors["name"] ? "border-red-500" : ""
|
||||||
}`}
|
}`}
|
||||||
placeholder="Operator *"
|
placeholder="Nombre del concentrador"
|
||||||
value={form["Operator"]}
|
value={form.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, Operator: e.target.value });
|
setForm({ ...form, name: e.target.value });
|
||||||
if (errors["Operator"])
|
if (errors["name"]) setErrors({ ...errors, name: false });
|
||||||
setErrors({ ...errors, Operator: false });
|
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{errors["Operator"] && (
|
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<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 ${
|
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" : ""
|
errors["projectId"] ? "border-red-500" : ""
|
||||||
}`}
|
}`}
|
||||||
placeholder="Instruction Manual *"
|
value={form.projectId}
|
||||||
value={form["Instruction Manual"]}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, "Instruction Manual": e.target.value });
|
setForm({ ...form, projectId: e.target.value });
|
||||||
if (errors["Instruction Manual"])
|
if (errors["projectId"]) setErrors({ ...errors, projectId: false });
|
||||||
setErrors({ ...errors, "Instruction Manual": false });
|
|
||||||
}}
|
}}
|
||||||
|
disabled={loadingProjects}
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
{errors["Instruction Manual"] && (
|
<option value="">
|
||||||
<p className="text-red-500 text-xs mt-1">
|
{loadingProjects ? "Cargando..." : "Selecciona un proyecto"}
|
||||||
This field is required
|
</option>
|
||||||
</p>
|
{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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GATEWAY */}
|
<div>
|
||||||
<div className="space-y-3 pt-4">
|
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
<input
|
||||||
Gateway Configuration
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
</h3>
|
placeholder="Ubicación del concentrador (opcional)"
|
||||||
|
value={form.location ?? ""}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
|
||||||
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<label className="block text-sm text-gray-600 mb-1">Tipo *</label>
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
<select
|
||||||
errors["Gateway Name"] ? "border-red-500" : ""
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
}`}
|
value={form.type ?? "LORA"}
|
||||||
placeholder="Gateway Name *"
|
onChange={(e) => setForm({ ...form, type: e.target.value as "LORA" | "LORAWAN" | "GRANDES" })}
|
||||||
value={gatewayForm["Gateway Name"]}
|
>
|
||||||
onChange={(e) => {
|
<option value="LORA">LoRa</option>
|
||||||
setGatewayForm({
|
<option value="LORAWAN">LoRaWAN</option>
|
||||||
...gatewayForm,
|
<option value="GRANDES">Grandes Consumidores</option>
|
||||||
"Gateway Name": e.target.value,
|
</select>
|
||||||
});
|
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">Estado</label>
|
||||||
<select
|
<select
|
||||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
value={gatewayForm["Antenna Placement"]}
|
value={form.status ?? "ACTIVE"}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
setGatewayForm({
|
|
||||||
...gatewayForm,
|
|
||||||
"Antenna Placement": e.target.value as "Indoor" | "Outdoor",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="Indoor">Indoor</option>
|
<option value="ACTIVE">Activo</option>
|
||||||
<option value="Outdoor">Outdoor</option>
|
<option value="INACTIVE">Inactivo</option>
|
||||||
|
<option value="MAINTENANCE">Mantenimiento</option>
|
||||||
|
<option value="OFFLINE">Sin conexión</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">Dirección IP</label>
|
||||||
<input
|
<input
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
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="192.168.1.100"
|
||||||
}`}
|
value={form.ipAddress ?? ""}
|
||||||
placeholder="Gateway Description *"
|
onChange={(e) => setForm({ ...form, ipAddress: e.target.value || undefined })}
|
||||||
value={gatewayForm["Gateway Description"]}
|
/>
|
||||||
onChange={(e) => {
|
</div>
|
||||||
setGatewayForm({
|
|
||||||
...gatewayForm,
|
<div>
|
||||||
"Gateway Description": e.target.value,
|
<label className="block text-sm text-gray-600 mb-1">Versión de Firmware</label>
|
||||||
});
|
<input
|
||||||
if (errors["Gateway Description"])
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
setErrors({ ...errors, "Gateway Description": false });
|
placeholder="v1.0.0"
|
||||||
}}
|
value={form.firmwareVersion ?? ""}
|
||||||
required
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ACTIONS */}
|
|
||||||
<div className="flex justify-end gap-2 pt-3 border-t">
|
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||||
<button
|
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
|
||||||
onClick={onClose}
|
Cancelar
|
||||||
className="px-4 py-2 rounded hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
||||||
>
|
>
|
||||||
Save
|
Guardar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
// src/pages/concentrators/ConcentratorsPage.tsx
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmModal from "../../components/layout/common/ConfirmModal";
|
import ConfirmModal from "../../components/layout/common/ConfirmModal";
|
||||||
|
import {
|
||||||
import { createConcentrator, deleteConcentrator, updateConcentrator, type Concentrator } from "../../api/concentrators";
|
createConcentrator,
|
||||||
|
deleteConcentrator,
|
||||||
// ✅ hook es named export y pide currentUser
|
updateConcentrator,
|
||||||
|
type Concentrator,
|
||||||
|
type ConcentratorInput,
|
||||||
|
} from "../../api/concentrators";
|
||||||
import { useConcentrators } from "./useConcentrators";
|
import { useConcentrators } from "./useConcentrators";
|
||||||
|
|
||||||
// ✅ UI pieces
|
|
||||||
import ConcentratorsSidebar from "./ConcentratorsSidebar";
|
import ConcentratorsSidebar from "./ConcentratorsSidebar";
|
||||||
import ConcentratorsTable from "./ConcentratorsTable";
|
import ConcentratorsTable from "./ConcentratorsTable";
|
||||||
import ConcentratorsModal from "./ConcentratorsModal";
|
import ConcentratorsModal from "./ConcentratorsModal";
|
||||||
|
|
||||||
|
|
||||||
export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
|
export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
|
||||||
export type ProjectStatus = "ACTIVO" | "INACTIVO";
|
export type ProjectStatus = "ACTIVO" | "INACTIVO";
|
||||||
export type ProjectCard = {
|
export type ProjectCard = {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
region: string;
|
region: string;
|
||||||
projects: number;
|
projects: number;
|
||||||
@@ -33,91 +33,53 @@ type User = {
|
|||||||
project?: string;
|
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() {
|
export default function ConcentratorsPage() {
|
||||||
// ✅ Simulación de usuario actual
|
|
||||||
const currentUser: User = {
|
const currentUser: User = {
|
||||||
role: "SUPER_ADMIN",
|
role: "SUPER_ADMIN",
|
||||||
project: "CESPT",
|
project: "CESPT",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Hook (solo cubre: projects + fetch + sampleView + selectedProject + loading + projectsData)
|
|
||||||
const c = useConcentrators(currentUser);
|
const c = useConcentrators(currentUser);
|
||||||
|
|
||||||
|
|
||||||
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
|
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeConcentrator, setActiveConcentrator] = useState<Concentrator | null>(null);
|
const [activeConcentrator, setActiveConcentrator] = useState<Concentrator | null>(null);
|
||||||
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const [showModal, setShowModal] = 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"> => ({
|
const getEmptyForm = (): ConcentratorInput => ({
|
||||||
"Area Name": c.selectedProject,
|
serialNumber: "",
|
||||||
"Device S/N": "",
|
name: "",
|
||||||
"Device Name": "",
|
projectId: "",
|
||||||
"Device Time": new Date().toISOString(),
|
location: "",
|
||||||
"Device Status": "ACTIVE",
|
type: "LORA",
|
||||||
Operator: "",
|
status: "ACTIVE",
|
||||||
"Installed Time": new Date().toISOString().slice(0, 10),
|
ipAddress: "",
|
||||||
"Communication Time": new Date().toISOString(),
|
firmwareVersion: "",
|
||||||
"Instruction Manual": "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getEmptyGatewayData = (): GatewayData => ({
|
const [form, setForm] = useState<ConcentratorInput>(getEmptyForm());
|
||||||
"Gateway ID": 0,
|
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||||
"Gateway EUI": "",
|
|
||||||
"Gateway Name": "",
|
|
||||||
"Gateway Description": "",
|
|
||||||
"Antenna Placement": "Indoor",
|
|
||||||
});
|
|
||||||
|
|
||||||
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(() => {
|
const searchFiltered = useMemo(() => {
|
||||||
if (!c.isGeneral) return [];
|
if (!c.isGeneral) return [];
|
||||||
return c.filteredConcentrators.filter((row) => {
|
return c.filteredConcentrators.filter((row) => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const name = (row["Device Name"] ?? "").toLowerCase();
|
const name = (row.name ?? "").toLowerCase();
|
||||||
const sn = (row["Device S/N"] ?? "").toLowerCase();
|
const sn = (row.serialNumber ?? "").toLowerCase();
|
||||||
return name.includes(q) || sn.includes(q);
|
return name.includes(q) || sn.includes(q);
|
||||||
});
|
});
|
||||||
}, [c.filteredConcentrators, c.isGeneral, search]);
|
}, [c.filteredConcentrators, c.isGeneral, search]);
|
||||||
|
|
||||||
// =========================
|
|
||||||
// CRUD (solo GENERAL)
|
|
||||||
// =========================
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const next: { [key: string]: boolean } = {};
|
const next: Record<string, boolean> = {};
|
||||||
|
|
||||||
if (!form["Device Name"].trim()) next["Device Name"] = true;
|
if (!form.name.trim()) next["name"] = true;
|
||||||
if (!form["Device S/N"].trim()) next["Device S/N"] = true;
|
if (!form.serialNumber.trim()) next["serialNumber"] = true;
|
||||||
if (!form["Operator"].trim()) next["Operator"] = true;
|
if (!form.projectId.trim()) next["projectId"] = 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;
|
|
||||||
|
|
||||||
setErrors(next);
|
setErrors(next);
|
||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
@@ -128,23 +90,17 @@ export default function ConcentratorsPage() {
|
|||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingSerial) {
|
if (editingId) {
|
||||||
const toUpdate = c.concentrators.find((x) => x["Device S/N"] === editingSerial);
|
const updated = await updateConcentrator(editingId, form);
|
||||||
if (!toUpdate) throw new Error("Concentrator not found");
|
c.setConcentrators((prev) => prev.map((x) => (x.id === editingId ? updated : x)));
|
||||||
|
|
||||||
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)));
|
|
||||||
} else {
|
} else {
|
||||||
const created = await createConcentrator(form);
|
const created = await createConcentrator(form);
|
||||||
c.setConcentrators((prev) => [...prev, created]);
|
c.setConcentrators((prev) => [...prev, created]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingSerial(null);
|
setEditingId(null);
|
||||||
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
|
setForm(getEmptyForm());
|
||||||
setGatewayForm(getEmptyGatewayData());
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setActiveConcentrator(null);
|
setActiveConcentrator(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -167,28 +123,32 @@ export default function ConcentratorsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =========================
|
const openEditModal = () => {
|
||||||
// Date helpers para modal
|
if (!c.isGeneral || !activeConcentrator) return;
|
||||||
// =========================
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDatetimeLocalValue(value: string) {
|
setEditingId(activeConcentrator.id);
|
||||||
if (!value) return "";
|
setForm({
|
||||||
const d = new Date(value);
|
serialNumber: activeConcentrator.serialNumber,
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
name: activeConcentrator.name,
|
||||||
return d.toISOString();
|
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 (
|
return (
|
||||||
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
||||||
@@ -202,8 +162,6 @@ export default function ConcentratorsPage() {
|
|||||||
onChangeSampleView={(next: SampleView) => {
|
onChangeSampleView={(next: SampleView) => {
|
||||||
c.setSampleView(next);
|
c.setSampleView(next);
|
||||||
setTypesMenuOpen(false);
|
setTypesMenuOpen(false);
|
||||||
|
|
||||||
// resets UI
|
|
||||||
c.setSelectedProject("");
|
c.setSelectedProject("");
|
||||||
setActiveConcentrator(null);
|
setActiveConcentrator(null);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
@@ -238,46 +196,15 @@ export default function ConcentratorsPage() {
|
|||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openCreateModal}
|
||||||
if (!c.isGeneral) return;
|
disabled={!c.isGeneral || c.allProjects.length === 0}
|
||||||
if (!c.selectedProject) return;
|
|
||||||
|
|
||||||
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
|
|
||||||
setGatewayForm(getEmptyGatewayData());
|
|
||||||
setErrors({});
|
|
||||||
setEditingSerial(null);
|
|
||||||
setShowModal(true);
|
|
||||||
}}
|
|
||||||
disabled={!c.isGeneral || !c.selectedProject}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
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
|
<Plus size={16} /> Agregar
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openEditModal}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
disabled={!c.isGeneral || !activeConcentrator}
|
disabled={!c.isGeneral || !activeConcentrator}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
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
|
<input
|
||||||
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
|
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}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
disabled={!c.isGeneral || !c.selectedProject}
|
disabled={!c.isGeneral || !c.selectedProject}
|
||||||
@@ -320,10 +247,10 @@ export default function ConcentratorsPage() {
|
|||||||
!c.isGeneral
|
!c.isGeneral
|
||||||
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
|
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
|
||||||
: !c.selectedProject
|
: !c.selectedProject
|
||||||
? "Select a project to view concentrators."
|
? "Selecciona un proyecto para ver los concentradores."
|
||||||
: c.loadingConcentrators
|
: c.loadingConcentrators
|
||||||
? "Loading concentrators..."
|
? "Cargando concentradores..."
|
||||||
: "No concentrators found. Click 'Add' to create your first concentrator."
|
: "No hay concentradores. Haz clic en 'Agregar' para crear uno."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,8 +259,8 @@ export default function ConcentratorsPage() {
|
|||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="Eliminar concentrador"
|
title="Eliminar concentrador"
|
||||||
message={`¿Estás seguro que quieres eliminar "${
|
message={`¿Estás seguro que quieres eliminar "${
|
||||||
activeConcentrator?.["Device Name"] ?? "este concentrador"
|
activeConcentrator?.name ?? "este concentrador"
|
||||||
}"? Esta acción no se puede deshacer.`}
|
}" (${activeConcentrator?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
|
||||||
confirmText="Eliminar"
|
confirmText="Eliminar"
|
||||||
cancelText="Cancelar"
|
cancelText="Cancelar"
|
||||||
danger
|
danger
|
||||||
@@ -354,18 +281,14 @@ export default function ConcentratorsPage() {
|
|||||||
|
|
||||||
{showModal && c.isGeneral && (
|
{showModal && c.isGeneral && (
|
||||||
<ConcentratorsModal
|
<ConcentratorsModal
|
||||||
editingSerial={editingSerial}
|
editingId={editingId}
|
||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
setForm={setForm}
|
||||||
gatewayForm={gatewayForm}
|
|
||||||
setGatewayForm={setGatewayForm}
|
|
||||||
errors={errors}
|
errors={errors}
|
||||||
setErrors={setErrors}
|
setErrors={setErrors}
|
||||||
toDatetimeLocalValue={toDatetimeLocalValue}
|
allProjects={c.allProjects}
|
||||||
fromDatetimeLocalValue={fromDatetimeLocalValue}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setGatewayForm(getEmptyGatewayData());
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
}}
|
}}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export default function ConcentratorsSidebar({
|
|||||||
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
|
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
|
||||||
{" • "}
|
{" • "}
|
||||||
Seleccionado:{" "}
|
Seleccionado:{" "}
|
||||||
<span className="font-semibold">{selectedProject || "—"}</span>
|
<span className="font-semibold">
|
||||||
|
{projects.find((p) => p.id === selectedProject)?.name || "—"}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,12 +134,12 @@ export default function ConcentratorsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map((p) => {
|
projects.map((p) => {
|
||||||
const active = p.name === selectedProject;
|
const active = p.id === selectedProject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.name}
|
key={p.id}
|
||||||
onClick={() => onSelectProject(p.name)}
|
onClick={() => onSelectProject(p.id)}
|
||||||
className={[
|
className={[
|
||||||
"rounded-xl border p-4 transition cursor-pointer",
|
"rounded-xl border p-4 transition cursor-pointer",
|
||||||
active
|
active
|
||||||
@@ -211,7 +213,7 @@ export default function ConcentratorsSidebar({
|
|||||||
].join(" ")}
|
].join(" ")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectProject(p.name);
|
onSelectProject(p.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{active ? "Seleccionado" : "Seleccionar"}
|
{active ? "Seleccionado" : "Seleccionar"}
|
||||||
|
|||||||
@@ -23,45 +23,69 @@ export default function ConcentratorsTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: "Device Name",
|
title: "Serial",
|
||||||
field: "Device Name",
|
field: "serialNumber",
|
||||||
render: (rowData: any) => rowData["Device Name"] || "-",
|
render: (rowData: Concentrator) => rowData.serialNumber || "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Device S/N",
|
title: "Nombre",
|
||||||
field: "Device S/N",
|
field: "name",
|
||||||
render: (rowData: any) => rowData["Device S/N"] || "-",
|
render: (rowData: Concentrator) => rowData.name || "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Device Status",
|
title: "Tipo",
|
||||||
field: "Device Status",
|
field: "type",
|
||||||
render: (rowData: any) => (
|
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
|
<span
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
|
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-blue-600 border-blue-600"
|
||||||
: "text-red-600 border-red-600"
|
: "text-red-600 border-red-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rowData["Device Status"] || "-"}
|
{rowData.status || "-"}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Operator",
|
title: "Ubicación",
|
||||||
field: "Operator",
|
field: "location",
|
||||||
render: (rowData: any) => rowData["Operator"] || "-",
|
render: (rowData: Concentrator) => rowData.location || "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Area Name",
|
title: "IP",
|
||||||
field: "Area Name",
|
field: "ipAddress",
|
||||||
render: (rowData: any) => rowData["Area Name"] || "-",
|
render: (rowData: Concentrator) => rowData.ipAddress || "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Installed Time",
|
title: "Última Comunicación",
|
||||||
field: "Installed Time",
|
field: "lastCommunication",
|
||||||
type: "date",
|
type: "datetime",
|
||||||
render: (rowData: any) => rowData["Installed Time"] || "-",
|
render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
fetchConcentrators,
|
fetchConcentrators,
|
||||||
type Concentrator,
|
type Concentrator,
|
||||||
} from "../../api/concentrators";
|
} from "../../api/concentrators";
|
||||||
|
import { fetchProjects, type Project } from "../../api/projects";
|
||||||
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
|
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@@ -16,6 +17,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||||
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
|
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [allProjects, setAllProjects] = useState<string[]>([]);
|
const [allProjects, setAllProjects] = useState<string[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
|
||||||
@@ -51,58 +53,49 @@ export function useConcentrators(currentUser: User) {
|
|||||||
[allProjects, currentUser.role, currentUser.project]
|
[allProjects, currentUser.role, currentUser.project]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadConcentrators = async () => {
|
const loadProjects = async () => {
|
||||||
if (!isGeneral) return;
|
|
||||||
|
|
||||||
setLoadingConcentrators(true);
|
|
||||||
setLoadingProjects(true);
|
setLoadingProjects(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await fetchConcentrators();
|
const projectsData = await fetchProjects();
|
||||||
|
setProjects(projectsData);
|
||||||
const normalized = raw.map((c: any) => {
|
const projectIds = projectsData.map((p) => p.id);
|
||||||
const preferredName =
|
setAllProjects(projectIds);
|
||||||
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);
|
|
||||||
|
|
||||||
setSelectedProject((prev) => {
|
setSelectedProject((prev) => {
|
||||||
if (prev) return prev;
|
if (prev) return prev;
|
||||||
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
|
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
|
||||||
return currentUser.project;
|
return currentUser.project;
|
||||||
}
|
}
|
||||||
return projectsArray[0] ?? "";
|
return projectIds[0] ?? "";
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading concentrators:", err);
|
console.error("Error loading projects:", err);
|
||||||
|
setProjects([]);
|
||||||
setAllProjects([]);
|
setAllProjects([]);
|
||||||
setConcentrators([]);
|
|
||||||
setSelectedProject("");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingConcentrators(false);
|
|
||||||
setLoadingProjects(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(() => {
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
loadConcentrators();
|
loadConcentrators();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -110,6 +103,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
// view changes
|
// view changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGeneral) {
|
if (isGeneral) {
|
||||||
|
loadProjects();
|
||||||
loadConcentrators();
|
loadConcentrators();
|
||||||
} else {
|
} else {
|
||||||
setLoadingProjects(false);
|
setLoadingProjects(false);
|
||||||
@@ -136,7 +130,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
|
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
setFilteredConcentrators(
|
setFilteredConcentrators(
|
||||||
concentrators.filter((c) => c["Area Name"] === selectedProject)
|
concentrators.filter((c) => c.projectId === selectedProject)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setFilteredConcentrators(concentrators);
|
setFilteredConcentrators(concentrators);
|
||||||
@@ -146,8 +140,13 @@ export function useConcentrators(currentUser: User) {
|
|||||||
// sidebar cards (general)
|
// sidebar cards (general)
|
||||||
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
|
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
|
||||||
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
|
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
|
||||||
const area = c["Area Name"] ?? "SIN PROYECTO";
|
const project = c.projectId ?? "SIN PROYECTO";
|
||||||
acc[area] = (acc[area] ?? 0) + 1;
|
acc[project] = (acc[project] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const projectNameMap = projects.reduce<Record<string, string>>((acc, p) => {
|
||||||
|
acc[p.id] = p.name;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
@@ -155,17 +154,18 @@ export function useConcentrators(currentUser: User) {
|
|||||||
const baseContact = "Operaciones";
|
const baseContact = "Operaciones";
|
||||||
const baseLastSync = "Hace 1 h";
|
const baseLastSync = "Hace 1 h";
|
||||||
|
|
||||||
return visibleProjects.map((name) => ({
|
return visibleProjects.map((projectId) => ({
|
||||||
name,
|
id: projectId,
|
||||||
|
name: projectNameMap[projectId] ?? projectId,
|
||||||
region: baseRegion,
|
region: baseRegion,
|
||||||
projects: 1,
|
projects: 1,
|
||||||
concentrators: counts[name] ?? 0,
|
concentrators: counts[projectId] ?? 0,
|
||||||
activeAlerts: 0,
|
activeAlerts: 0,
|
||||||
lastSync: baseLastSync,
|
lastSync: baseLastSync,
|
||||||
contact: baseContact,
|
contact: baseContact,
|
||||||
status: "ACTIVO",
|
status: "ACTIVO" as const,
|
||||||
}));
|
}));
|
||||||
}, [concentrators, visibleProjects]);
|
}, [concentrators, visibleProjects, projects]);
|
||||||
|
|
||||||
// sidebar cards (mock)
|
// sidebar cards (mock)
|
||||||
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
|
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
|
||||||
@@ -173,6 +173,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
() => ({
|
() => ({
|
||||||
LORA: [
|
LORA: [
|
||||||
{
|
{
|
||||||
|
id: "mock-lora-centro",
|
||||||
name: "LoRa - Zona Centro",
|
name: "LoRa - Zona Centro",
|
||||||
region: "Baja California",
|
region: "Baja California",
|
||||||
projects: 1,
|
projects: 1,
|
||||||
@@ -183,6 +184,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
status: "ACTIVO",
|
status: "ACTIVO",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "mock-lora-este",
|
||||||
name: "LoRa - Zona Este",
|
name: "LoRa - Zona Este",
|
||||||
region: "Baja California",
|
region: "Baja California",
|
||||||
projects: 1,
|
projects: 1,
|
||||||
@@ -195,6 +197,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
],
|
],
|
||||||
LORAWAN: [
|
LORAWAN: [
|
||||||
{
|
{
|
||||||
|
id: "mock-lorawan-industrial",
|
||||||
name: "LoRaWAN - Industrial",
|
name: "LoRaWAN - Industrial",
|
||||||
region: "Baja California",
|
region: "Baja California",
|
||||||
projects: 1,
|
projects: 1,
|
||||||
@@ -207,6 +210,7 @@ export function useConcentrators(currentUser: User) {
|
|||||||
],
|
],
|
||||||
GRANDES: [
|
GRANDES: [
|
||||||
{
|
{
|
||||||
|
id: "mock-grandes-convenios",
|
||||||
name: "Grandes - Convenios",
|
name: "Grandes - Convenios",
|
||||||
region: "Baja California",
|
region: "Baja California",
|
||||||
projects: 1,
|
projects: 1,
|
||||||
|
|||||||
580
src/pages/consumption/ConsumptionPage.tsx
Normal file
580
src/pages/consumption/ConsumptionPage.tsx
Normal 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"} m³`}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/pages/consumption/ReadingsBulkUploadModal.tsx
Normal file
210
src/pages/consumption/ReadingsBulkUploadModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
import { Plus, Trash2, Pencil, RefreshCcw, Upload } from "lucide-react";
|
||||||
import type { Meter } from "../../api/meters";
|
import type { Meter, MeterInput } from "../../api/meters";
|
||||||
import { createMeter, deleteMeter, updateMeter } from "../../api/meters";
|
import { createMeter, deleteMeter, updateMeter } from "../../api/meters";
|
||||||
import ConfirmModal from "../../components/layout/common/ConfirmModal";
|
import ConfirmModal from "../../components/layout/common/ConfirmModal";
|
||||||
|
|
||||||
@@ -8,16 +8,9 @@ import { useMeters } from "./useMeters";
|
|||||||
import MetersSidebar from "./MetersSidebar";
|
import MetersSidebar from "./MetersSidebar";
|
||||||
import MetersTable from "./MetersTable";
|
import MetersTable from "./MetersTable";
|
||||||
import MetersModal from "./MetersModal";
|
import MetersModal from "./MetersModal";
|
||||||
|
import MetersBulkUploadModal from "./MetersBulkUploadModal";
|
||||||
|
|
||||||
/* ================= TYPES (exportables para otros componentes) ================= */
|
/* ================= TYPES ================= */
|
||||||
|
|
||||||
export interface DeviceData {
|
|
||||||
"Device ID": number;
|
|
||||||
"Device EUI": string;
|
|
||||||
"Join EUI": string;
|
|
||||||
AppKey: string;
|
|
||||||
meterId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProjectStatus = "ACTIVO" | "INACTIVO";
|
export type ProjectStatus = "ACTIVO" | "INACTIVO";
|
||||||
|
|
||||||
@@ -34,20 +27,6 @@ export type ProjectCard = {
|
|||||||
|
|
||||||
export type TakeType = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
|
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 ================= */
|
/* ================= COMPONENT ================= */
|
||||||
|
|
||||||
export default function MetersPage({
|
export default function MetersPage({
|
||||||
@@ -68,46 +47,27 @@ export default function MetersPage({
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = 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(),
|
serialNumber: "",
|
||||||
updatedAt: new Date().toISOString(),
|
meterId: "",
|
||||||
areaName: "",
|
name: "",
|
||||||
accountNumber: null,
|
concentratorId: "",
|
||||||
userName: null,
|
location: "",
|
||||||
userAddress: null,
|
type: "LORA",
|
||||||
meterSerialNumber: "",
|
status: "ACTIVE",
|
||||||
meterName: "",
|
installationDate: new Date().toISOString(),
|
||||||
meterStatus: "Installed",
|
|
||||||
protocolType: "",
|
|
||||||
priceNo: null,
|
|
||||||
priceName: null,
|
|
||||||
dmaPartition: null,
|
|
||||||
supplyTypes: "",
|
|
||||||
deviceId: "",
|
|
||||||
deviceName: "",
|
|
||||||
deviceType: "",
|
|
||||||
usageAnalysisType: "",
|
|
||||||
installedTime: new Date().toISOString(),
|
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyDeviceData: DeviceData = useMemo(
|
const [form, setForm] = useState<MeterInput>(emptyForm);
|
||||||
() => ({
|
|
||||||
"Device ID": 0,
|
|
||||||
"Device EUI": "",
|
|
||||||
"Join EUI": "",
|
|
||||||
AppKey: "",
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<Omit<Meter, "id">>(emptyMeter);
|
|
||||||
const [deviceForm, setDeviceForm] = useState<DeviceData>(emptyDeviceData);
|
|
||||||
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// Projects cards (real)
|
// Projects cards (from real data)
|
||||||
const projectsDataReal: ProjectCard[] = useMemo(() => {
|
const projectsDataReal: ProjectCard[] = useMemo(() => {
|
||||||
const baseRegion = "Baja California";
|
const baseRegion = "Baja California";
|
||||||
const baseContact = "Operaciones";
|
const baseContact = "Operaciones";
|
||||||
@@ -121,32 +81,13 @@ export default function MetersPage({
|
|||||||
activeAlerts: 0,
|
activeAlerts: 0,
|
||||||
lastSync: baseLastSync,
|
lastSync: baseLastSync,
|
||||||
contact: baseContact,
|
contact: baseContact,
|
||||||
status: "ACTIVO",
|
status: "ACTIVO" as ProjectStatus,
|
||||||
}));
|
}));
|
||||||
}, [m.allProjects, m.projectsCounts]);
|
}, [m.allProjects, m.projectsCounts]);
|
||||||
|
|
||||||
// Projects cards (mock)
|
const sidebarProjects = isMockMode ? [] : projectsDataReal;
|
||||||
const projectsDataMock: ProjectCard[] = useMemo(() => {
|
|
||||||
const baseRegion = "Baja California";
|
|
||||||
const baseContact = "Operaciones";
|
|
||||||
const baseLastSync = "Hace 1 h";
|
|
||||||
|
|
||||||
const mocks = MOCK_PROJECTS_BY_TYPE[takeType as Exclude<TakeType, "GENERAL">] ?? [];
|
// Search filtered meters
|
||||||
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
|
|
||||||
const searchFiltered = useMemo(() => {
|
const searchFiltered = useMemo(() => {
|
||||||
if (isMockMode) return [];
|
if (isMockMode) return [];
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
@@ -154,76 +95,43 @@ export default function MetersPage({
|
|||||||
|
|
||||||
return m.filteredMeters.filter((x) => {
|
return m.filteredMeters.filter((x) => {
|
||||||
return (
|
return (
|
||||||
(x.meterName ?? "").toLowerCase().includes(q) ||
|
(x.name ?? "").toLowerCase().includes(q) ||
|
||||||
(x.meterSerialNumber ?? "").toLowerCase().includes(q) ||
|
(x.serialNumber ?? "").toLowerCase().includes(q) ||
|
||||||
(x.deviceId ?? "").toLowerCase().includes(q) ||
|
(x.location ?? "").toLowerCase().includes(q) ||
|
||||||
(x.areaName ?? "").toLowerCase().includes(q)
|
(x.concentratorName ?? "").toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [isMockMode, search, m.filteredMeters]);
|
}, [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
|
// Validation
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const next: Record<string, boolean> = {};
|
const next: Record<string, boolean> = {};
|
||||||
|
|
||||||
if (!form.meterName.trim()) next["meterName"] = true;
|
if (!form.name.trim()) next["name"] = true;
|
||||||
if (!form.meterSerialNumber.trim()) next["meterSerialNumber"] = true;
|
if (!form.serialNumber.trim()) next["serialNumber"] = true;
|
||||||
if (!form.areaName.trim()) next["areaName"] = true;
|
if (!form.concentratorId.trim()) next["concentratorId"] = 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;
|
|
||||||
|
|
||||||
setErrors(next);
|
setErrors(next);
|
||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// CRUD
|
// CRUD handlers
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (isMockMode) return;
|
if (isMockMode) return;
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let savedMeter: Meter;
|
|
||||||
|
|
||||||
if (editingId) {
|
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);
|
const updatedMeter = await updateMeter(editingId, form);
|
||||||
m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x)));
|
m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x)));
|
||||||
savedMeter = updatedMeter;
|
|
||||||
} else {
|
} else {
|
||||||
const newMeter = await createMeter(form);
|
const newMeter = await createMeter(form);
|
||||||
m.setMeters((prev) => [...prev, newMeter]);
|
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);
|
setShowModal(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm(emptyMeter);
|
setForm(emptyForm);
|
||||||
setDeviceForm(emptyDeviceData);
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setActiveMeter(null);
|
setActiveMeter(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -260,6 +168,33 @@ export default function MetersPage({
|
|||||||
setSearch("");
|
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 (
|
return (
|
||||||
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
||||||
{/* SIDEBAR */}
|
{/* SIDEBAR */}
|
||||||
@@ -296,55 +231,23 @@ export default function MetersPage({
|
|||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openCreateModal}
|
||||||
if (isMockMode) return;
|
disabled={isMockMode || m.allProjects.length === 0}
|
||||||
|
|
||||||
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}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
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
|
<Plus size={16} /> Agregar
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setShowBulkUpload(true)}
|
||||||
if (isMockMode) return;
|
disabled={isMockMode}
|
||||||
if (!activeMeter) return;
|
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);
|
<button
|
||||||
setForm({
|
onClick={openEditModal}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
disabled={isMockMode || !activeMeter}
|
disabled={isMockMode || !activeMeter}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
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
|
<input
|
||||||
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
|
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}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
disabled={isMockMode || !m.selectedProject}
|
disabled={isMockMode || !m.selectedProject}
|
||||||
@@ -392,8 +295,8 @@ export default function MetersPage({
|
|||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="Eliminar medidor"
|
title="Eliminar medidor"
|
||||||
message={`¿Estás seguro que quieres eliminar "${
|
message={`¿Estás seguro que quieres eliminar "${
|
||||||
activeMeter?.meterName ?? "este medidor"
|
activeMeter?.name ?? "este medidor"
|
||||||
}" (${activeMeter?.meterSerialNumber ?? "—"})? Esta acción no se puede deshacer.`}
|
}" (${activeMeter?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
|
||||||
confirmText="Eliminar"
|
confirmText="Eliminar"
|
||||||
cancelText="Cancelar"
|
cancelText="Cancelar"
|
||||||
danger
|
danger
|
||||||
@@ -416,18 +319,27 @@ export default function MetersPage({
|
|||||||
editingId={editingId}
|
editingId={editingId}
|
||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
setForm={setForm}
|
||||||
deviceForm={deviceForm}
|
|
||||||
setDeviceForm={setDeviceForm}
|
|
||||||
errors={errors}
|
errors={errors}
|
||||||
setErrors={setErrors}
|
setErrors={setErrors}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setDeviceForm(emptyDeviceData);
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
}}
|
}}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBulkUpload && (
|
||||||
|
<MetersBulkUploadModal
|
||||||
|
onClose={() => {
|
||||||
|
m.loadMeters();
|
||||||
|
setShowBulkUpload(false);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
m.loadMeters();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
210
src/pages/meters/MetersBulkUploadModal.tsx
Normal file
210
src/pages/meters/MetersBulkUploadModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { Meter } from "../../api/meters";
|
import { useEffect, useState } from "react";
|
||||||
import type { DeviceData } from "./MeterPage";
|
import type { MeterInput } from "../../api/meters";
|
||||||
|
import { fetchConcentrators, type Concentrator } from "../../api/concentrators";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
|
|
||||||
form: Omit<Meter, "id">;
|
form: MeterInput;
|
||||||
setForm: React.Dispatch<React.SetStateAction<Omit<Meter, "id">>>;
|
setForm: React.Dispatch<React.SetStateAction<MeterInput>>;
|
||||||
|
|
||||||
deviceForm: DeviceData;
|
|
||||||
setDeviceForm: React.Dispatch<React.SetStateAction<DeviceData>>;
|
|
||||||
|
|
||||||
errors: Record<string, boolean>;
|
errors: Record<string, boolean>;
|
||||||
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
@@ -22,245 +20,183 @@ export default function MetersModal({
|
|||||||
editingId,
|
editingId,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
deviceForm,
|
|
||||||
setDeviceForm,
|
|
||||||
errors,
|
errors,
|
||||||
setErrors,
|
setErrors,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
<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>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
|
||||||
{/* FORM */}
|
{/* FORM */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||||
Meter Information
|
Información del Medidor
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
|
||||||
<input
|
<input
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
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 *"
|
placeholder="Número de serie"
|
||||||
value={form.areaName}
|
value={form.serialNumber}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, areaName: e.target.value });
|
setForm({ ...form, serialNumber: e.target.value });
|
||||||
if (errors["areaName"]) setErrors({ ...errors, areaName: false });
|
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{errors["areaName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
{errors["serialNumber"] && (
|
||||||
</div>
|
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<label className="block text-sm text-gray-600 mb-1">Meter ID</label>
|
||||||
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>
|
|
||||||
<input
|
<input
|
||||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="Device ID (optional)"
|
placeholder="ID del medidor (opcional)"
|
||||||
value={form.deviceId ?? ""}
|
value={form.meterId ?? ""}
|
||||||
onChange={(e) => setForm({ ...form, deviceId: e.target.value || "" })}
|
onChange={(e) => setForm({ ...form, meterId: e.target.value || undefined })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
|
||||||
<input
|
<input
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
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 *"
|
placeholder="Nombre del medidor"
|
||||||
value={form.deviceName}
|
value={form.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, deviceName: e.target.value });
|
setForm({ ...form, name: e.target.value });
|
||||||
if (errors["deviceName"]) setErrors({ ...errors, deviceName: false });
|
if (errors["name"]) setErrors({ ...errors, name: false });
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{errors["deviceName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DEVICE CONFIG */}
|
<div>
|
||||||
<div className="space-y-3 pt-4">
|
<label className="block text-sm text-gray-600 mb-1">Concentrador *</label>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
<select
|
||||||
Device Configuration
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
</h3>
|
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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<label className="block text-sm text-gray-600 mb-1">Tipo</label>
|
||||||
type="number"
|
<select
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
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" : ""
|
value={form.type ?? "LORA"}
|
||||||
}`}
|
onChange={(e) => setForm({ ...form, type: e.target.value })}
|
||||||
placeholder="Device ID *"
|
>
|
||||||
value={deviceForm["Device ID"] || ""}
|
<option value="LORA">LoRa</option>
|
||||||
onChange={(e) => {
|
<option value="LORAWAN">LoRaWAN</option>
|
||||||
setDeviceForm({ ...deviceForm, "Device ID": parseInt(e.target.value) || 0 });
|
<option value="GRANDES">Grandes Consumidores</option>
|
||||||
if (errors["Device ID"]) setErrors({ ...errors, "Device ID": false });
|
</select>
|
||||||
}}
|
|
||||||
required
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
{errors["Device ID"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<label className="block text-sm text-gray-600 mb-1">Estado</label>
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
<select
|
||||||
errors["Device EUI"] ? "border-red-500" : ""
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
}`}
|
value={form.status ?? "ACTIVE"}
|
||||||
placeholder="Device EUI *"
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
value={deviceForm["Device EUI"]}
|
>
|
||||||
onChange={(e) => {
|
<option value="ACTIVE">Activo</option>
|
||||||
setDeviceForm({ ...deviceForm, "Device EUI": e.target.value });
|
<option value="INACTIVE">Inactivo</option>
|
||||||
if (errors["Device EUI"]) setErrors({ ...errors, "Device EUI": false });
|
<option value="MAINTENANCE">Mantenimiento</option>
|
||||||
}}
|
<option value="FAULTY">Averiado</option>
|
||||||
required
|
<option value="REPLACED">Reemplazado</option>
|
||||||
/>
|
</select>
|
||||||
{errors["Device EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">Fecha de Instalación</label>
|
||||||
<input
|
<input
|
||||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
type="date"
|
||||||
errors["Join EUI"] ? "border-red-500" : ""
|
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] ?? ""}
|
||||||
placeholder="Join EUI *"
|
onChange={(e) =>
|
||||||
value={deviceForm["Join EUI"]}
|
setForm({
|
||||||
onChange={(e) => {
|
...form,
|
||||||
setDeviceForm({ ...deviceForm, "Join EUI": e.target.value });
|
installationDate: e.target.value ? new Date(e.target.value).toISOString() : undefined,
|
||||||
if (errors["Join EUI"]) setErrors({ ...errors, "Join EUI": false });
|
})
|
||||||
}}
|
}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<div className="flex justify-end gap-2 pt-3 border-t">
|
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||||
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
|
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
|
||||||
Cancel
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
||||||
>
|
>
|
||||||
Save
|
Guardar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,15 +28,51 @@ export default function MetersTable({
|
|||||||
title="Meters"
|
title="Meters"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "Area Name", field: "areaName", render: (r: any) => r.areaName || "-" },
|
{ title: "Serial", field: "serialNumber", render: (r: Meter) => r.serialNumber || "-" },
|
||||||
{ title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" },
|
{ title: "Meter ID", field: "meterId", render: (r: Meter) => r.meterId || "-" },
|
||||||
{ title: "User Name", field: "userName", render: (r: any) => r.userName || "-" },
|
{ title: "Nombre", field: "name", render: (r: Meter) => r.name || "-" },
|
||||||
{ title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" },
|
{ title: "Ubicación", field: "location", render: (r: Meter) => r.location || "-" },
|
||||||
{ title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" },
|
{
|
||||||
{ title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" },
|
title: "Tipo",
|
||||||
{ title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" },
|
field: "type",
|
||||||
{ title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" },
|
render: (r: Meter) => {
|
||||||
{ title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" },
|
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}
|
data={disabled ? [] : data}
|
||||||
onRowClick={(_, rowData) => onRowClick(rowData as Meter)}
|
onRowClick={(_, rowData) => onRowClick(rowData as Meter)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { fetchMeters, type Meter } from "../../api/meters";
|
import { fetchMeters, type Meter } from "../../api/meters";
|
||||||
|
import { fetchProjects } from "../../api/projects";
|
||||||
|
|
||||||
type UseMetersArgs = {
|
type UseMetersArgs = {
|
||||||
initialProject?: string;
|
initialProject?: string;
|
||||||
@@ -15,37 +16,43 @@ export function useMeters({ initialProject }: UseMetersArgs) {
|
|||||||
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
|
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
|
||||||
const [loadingMeters, setLoadingMeters] = useState(true);
|
const [loadingMeters, setLoadingMeters] = useState(true);
|
||||||
|
|
||||||
const loadMeters = async () => {
|
const loadProjects = async () => {
|
||||||
setLoadingMeters(true);
|
|
||||||
setLoadingProjects(true);
|
setLoadingProjects(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchMeters();
|
const projects = await fetchProjects();
|
||||||
|
const projectNames = projects.map((p) => p.name);
|
||||||
const projectsArray = [...new Set(data.map((r) => r.areaName))]
|
setAllProjects(projectNames);
|
||||||
.filter(Boolean) as string[];
|
|
||||||
|
|
||||||
setAllProjects(projectsArray);
|
|
||||||
setMeters(data);
|
|
||||||
|
|
||||||
setSelectedProject((prev) => {
|
setSelectedProject((prev) => {
|
||||||
if (prev) return prev;
|
if (prev) return prev;
|
||||||
if (initialProject) return initialProject;
|
if (initialProject) return initialProject;
|
||||||
return projectsArray[0] ?? "";
|
return projectNames[0] ?? "";
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading meters:", error);
|
console.error("Error loading projects:", error);
|
||||||
setAllProjects([]);
|
setAllProjects([]);
|
||||||
setMeters([]);
|
|
||||||
setSelectedProject("");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMeters(false);
|
|
||||||
setLoadingProjects(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(() => {
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
loadMeters();
|
loadMeters();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -61,13 +68,13 @@ export function useMeters({ initialProject }: UseMetersArgs) {
|
|||||||
setFilteredMeters([]);
|
setFilteredMeters([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFilteredMeters(meters.filter((m) => m.areaName === selectedProject));
|
setFilteredMeters(meters.filter((m) => m.projectName === selectedProject));
|
||||||
}, [selectedProject, meters]);
|
}, [selectedProject, meters]);
|
||||||
|
|
||||||
const projectsCounts = useMemo(() => {
|
const projectsCounts = useMemo(() => {
|
||||||
return meters.reduce<Record<string, number>>((acc, m) => {
|
return meters.reduce<Record<string, number>>((acc, m) => {
|
||||||
const area = m.areaName ?? "SIN PROYECTO";
|
const project = m.projectName ?? "SIN PROYECTO";
|
||||||
acc[area] = (acc[area] ?? 0) + 1;
|
acc[project] = (acc[project] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}, [meters]);
|
}, [meters]);
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
|||||||
import MaterialTable from "@material-table/core";
|
import MaterialTable from "@material-table/core";
|
||||||
import {
|
import {
|
||||||
Project,
|
Project,
|
||||||
|
ProjectInput,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
createProject as apiCreateProject,
|
createProject as apiCreateProject,
|
||||||
updateProject as apiUpdateProject,
|
updateProject as apiUpdateProject,
|
||||||
deleteProject as apiDeleteProject,
|
deleteProject as apiDeleteProject,
|
||||||
} from "../../api/projects";
|
} from "../../api/projects";
|
||||||
|
|
||||||
/* ================= COMPONENT ================= */
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -19,20 +19,16 @@ export default function ProjectsPage() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const emptyProject: Omit<Project, "id"> = {
|
const emptyForm: ProjectInput = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
areaName: "",
|
areaName: "",
|
||||||
deviceSN: "",
|
location: "",
|
||||||
deviceName: "",
|
status: "ACTIVE",
|
||||||
deviceType: "",
|
|
||||||
deviceStatus: "ACTIVE",
|
|
||||||
operator: "",
|
|
||||||
installedTime: "",
|
|
||||||
communicationTime: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [form, setForm] = useState<Omit<Project, "id">>(emptyProject);
|
const [form, setForm] = useState<ProjectInput>(emptyForm);
|
||||||
|
|
||||||
/* ================= LOAD ================= */
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +46,6 @@ export default function ProjectsPage() {
|
|||||||
loadProjects();
|
loadProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -65,7 +60,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm(emptyProject);
|
setForm(emptyForm);
|
||||||
setActiveProject(null);
|
setActiveProject(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving project:", error);
|
console.error("Error saving project:", error);
|
||||||
@@ -81,7 +76,7 @@ export default function ProjectsPage() {
|
|||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
const confirmDelete = window.confirm(
|
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;
|
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) =>
|
const filtered = projects.filter((p) =>
|
||||||
`${p.areaName} ${p.deviceName} ${p.deviceSN}`
|
`${p.name} ${p.areaName} ${p.description ?? ""}`
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search.toLowerCase())
|
.includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ================= UI ================= */
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
||||||
<div className="flex-1 flex flex-col gap-6">
|
<div className="flex-1 flex flex-col gap-6">
|
||||||
@@ -120,41 +132,23 @@ export default function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Project Management</h1>
|
<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>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openCreateModal}
|
||||||
setForm(emptyProject);
|
|
||||||
setEditingId(null);
|
|
||||||
setShowModal(true);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openEditModal}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
disabled={!activeProject}
|
disabled={!activeProject}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -162,14 +156,14 @@ export default function ProjectsPage() {
|
|||||||
disabled={!activeProject}
|
disabled={!activeProject}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={loadProjects}
|
onClick={loadProjects}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,38 +171,40 @@ export default function ProjectsPage() {
|
|||||||
{/* SEARCH */}
|
{/* SEARCH */}
|
||||||
<input
|
<input
|
||||||
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
|
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
|
||||||
placeholder="Search project..."
|
placeholder="Buscar proyecto..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TABLE */}
|
{/* TABLE */}
|
||||||
<MaterialTable
|
<MaterialTable
|
||||||
title="Projects"
|
title="Proyectos"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "Area Name", field: "areaName" },
|
{ title: "Nombre", field: "name" },
|
||||||
{ title: "Device S/N", field: "deviceSN" },
|
{ title: "Area", field: "areaName" },
|
||||||
{ title: "Device Name", field: "deviceName" },
|
{ title: "Descripción", field: "description", render: (rowData: Project) => rowData.description || "-" },
|
||||||
{ title: "Device Type", field: "deviceType" },
|
{ title: "Ubicación", field: "location", render: (rowData: Project) => rowData.location || "-" },
|
||||||
{
|
{
|
||||||
title: "Status",
|
title: "Estado",
|
||||||
field: "deviceStatus",
|
field: "status",
|
||||||
render: (rowData) => (
|
render: (rowData: Project) => (
|
||||||
<span
|
<span
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
|
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-blue-600 border-blue-600"
|
||||||
: "text-red-600 border-red-600"
|
: "text-red-600 border-red-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rowData.deviceStatus}
|
{rowData.status}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: "Operator", field: "operator" },
|
{
|
||||||
{ title: "Installed Time", field: "installedTime" },
|
title: "Creado",
|
||||||
{ title: "Communication Name", field: "communicationTime" },
|
field: "createdAt",
|
||||||
|
render: (rowData: Project) => new Date(rowData.createdAt).toLocaleDateString(),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
data={filtered}
|
data={filtered}
|
||||||
onRowClick={(_, rowData) => setActiveProject(rowData as Project)}
|
onRowClick={(_, rowData) => setActiveProject(rowData as Project)}
|
||||||
@@ -226,8 +222,8 @@ export default function ProjectsPage() {
|
|||||||
localization={{
|
localization={{
|
||||||
body: {
|
body: {
|
||||||
emptyDataSourceMessage: loading
|
emptyDataSourceMessage: loading
|
||||||
? "Loading projects..."
|
? "Cargando proyectos..."
|
||||||
: "No projects found. Click 'Add' to create your first project.",
|
: "No hay proyectos. Haz clic en 'Agregar' para crear uno.",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -235,85 +231,78 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
{/* MODAL */}
|
{/* MODAL */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-xl p-6 w-96 space-y-3">
|
<div className="bg-white rounded-xl p-6 w-[450px] space-y-4">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{editingId ? "Edit Project" : "Add Project"}
|
{editingId ? "Editar Proyecto" : "Agregar Proyecto"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
className="w-full border px-3 py-2 rounded"
|
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
|
||||||
placeholder="Area Name"
|
<input
|
||||||
value={form.areaName}
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
onChange={(e) => setForm({ ...form, areaName: e.target.value })}
|
placeholder="Nombre del proyecto"
|
||||||
/>
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
className="w-full border px-3 py-2 rounded"
|
<label className="block text-sm text-gray-600 mb-1">Area *</label>
|
||||||
placeholder="Device S/N"
|
<input
|
||||||
value={form.deviceSN}
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
onChange={(e) => setForm({ ...form, deviceSN: e.target.value })}
|
placeholder="Nombre del area"
|
||||||
/>
|
value={form.areaName}
|
||||||
|
onChange={(e) => setForm({ ...form, areaName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
className="w-full border px-3 py-2 rounded"
|
<label className="block text-sm text-gray-600 mb-1">Descripción</label>
|
||||||
placeholder="Device Name"
|
<textarea
|
||||||
value={form.deviceName}
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
onChange={(e) => setForm({ ...form, deviceName: e.target.value })}
|
placeholder="Descripción del proyecto (opcional)"
|
||||||
/>
|
rows={3}
|
||||||
|
value={form.description ?? ""}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
className="w-full border px-3 py-2 rounded"
|
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
|
||||||
placeholder="Device Type"
|
<input
|
||||||
value={form.deviceType}
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
onChange={(e) => setForm({ ...form, deviceType: e.target.value })}
|
placeholder="Ubicación (opcional)"
|
||||||
/>
|
value={form.location ?? ""}
|
||||||
|
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
className="w-full border px-3 py-2 rounded"
|
<label className="block text-sm text-gray-600 mb-1">Estado</label>
|
||||||
placeholder="Operator"
|
<select
|
||||||
value={form.operator}
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
onChange={(e) => setForm({ ...form, operator: e.target.value })}
|
value={form.status ?? "ACTIVE"}
|
||||||
/>
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">Activo</option>
|
||||||
|
<option value="INACTIVE">Inactivo</option>
|
||||||
|
<option value="SUSPENDED">Suspendido</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||||
className="w-full border px-3 py-2 rounded"
|
<button
|
||||||
placeholder="Installed Time"
|
onClick={() => setShowModal(false)}
|
||||||
value={form.installedTime}
|
className="px-4 py-2 rounded hover:bg-gray-100"
|
||||||
onChange={(e) =>
|
>
|
||||||
setForm({ ...form, installedTime: e.target.value })
|
Cancelar
|
||||||
}
|
</button>
|
||||||
/>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Status: {form.deviceStatus}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
|
||||||
<button onClick={() => setShowModal(false)}>Cancel</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
water-api/.env.example
Normal file
26
water-api/.env.example
Normal 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
86
water-api/.gitignore
vendored
Normal 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
45
water-api/package.json
Normal 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
434
water-api/sql/schema.sql
Normal 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
|
||||||
|
-- ============================================================================
|
||||||
0
water-api/src/config/.gitkeep
Normal file
0
water-api/src/config/.gitkeep
Normal file
113
water-api/src/config/database.ts
Normal file
113
water-api/src/config/database.ts
Normal 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;
|
||||||
128
water-api/src/config/index.ts
Normal file
128
water-api/src/config/index.ts
Normal 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;
|
||||||
0
water-api/src/controllers/.gitkeep
Normal file
0
water-api/src/controllers/.gitkeep
Normal file
138
water-api/src/controllers/auth.controller.ts
Normal file
138
water-api/src/controllers/auth.controller.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
147
water-api/src/controllers/bulk-upload.controller.ts
Normal file
147
water-api/src/controllers/bulk-upload.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
202
water-api/src/controllers/concentrator.controller.ts
Normal file
202
water-api/src/controllers/concentrator.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
225
water-api/src/controllers/device.controller.ts
Normal file
225
water-api/src/controllers/device.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
237
water-api/src/controllers/gateway.controller.ts
Normal file
237
water-api/src/controllers/gateway.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
284
water-api/src/controllers/meter.controller.ts
Normal file
284
water-api/src/controllers/meter.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
227
water-api/src/controllers/project.controller.ts
Normal file
227
water-api/src/controllers/project.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
158
water-api/src/controllers/reading.controller.ts
Normal file
158
water-api/src/controllers/reading.controller.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
222
water-api/src/controllers/role.controller.ts
Normal file
222
water-api/src/controllers/role.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
194
water-api/src/controllers/tts.controller.ts
Normal file
194
water-api/src/controllers/tts.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
352
water-api/src/controllers/user.controller.ts
Normal file
352
water-api/src/controllers/user.controller.ts
Normal 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
97
water-api/src/index.ts
Normal 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;
|
||||||
0
water-api/src/middleware/.gitkeep
Normal file
0
water-api/src/middleware/.gitkeep
Normal file
84
water-api/src/middleware/auth.middleware.ts
Normal file
84
water-api/src/middleware/auth.middleware.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
293
water-api/src/middleware/ttsWebhook.middleware.ts
Normal file
293
water-api/src/middleware/ttsWebhook.middleware.ts
Normal 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,
|
||||||
|
};
|
||||||
0
water-api/src/models/.gitkeep
Normal file
0
water-api/src/models/.gitkeep
Normal file
0
water-api/src/routes/.gitkeep
Normal file
0
water-api/src/routes/.gitkeep
Normal file
41
water-api/src/routes/auth.routes.ts
Normal file
41
water-api/src/routes/auth.routes.ts
Normal 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;
|
||||||
40
water-api/src/routes/bulk-upload.routes.ts
Normal file
40
water-api/src/routes/bulk-upload.routes.ts
Normal 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;
|
||||||
59
water-api/src/routes/concentrator.routes.ts
Normal file
59
water-api/src/routes/concentrator.routes.ts
Normal 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;
|
||||||
66
water-api/src/routes/device.routes.ts
Normal file
66
water-api/src/routes/device.routes.ts
Normal 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;
|
||||||
66
water-api/src/routes/gateway.routes.ts
Normal file
66
water-api/src/routes/gateway.routes.ts
Normal 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;
|
||||||
133
water-api/src/routes/index.ts
Normal file
133
water-api/src/routes/index.ts
Normal 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;
|
||||||
61
water-api/src/routes/meter.routes.ts
Normal file
61
water-api/src/routes/meter.routes.ts
Normal 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;
|
||||||
56
water-api/src/routes/project.routes.ts
Normal file
56
water-api/src/routes/project.routes.ts
Normal 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;
|
||||||
48
water-api/src/routes/reading.routes.ts
Normal file
48
water-api/src/routes/reading.routes.ts
Normal 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;
|
||||||
50
water-api/src/routes/role.routes.ts
Normal file
50
water-api/src/routes/role.routes.ts
Normal 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;
|
||||||
148
water-api/src/routes/tts.routes.ts
Normal file
148
water-api/src/routes/tts.routes.ts
Normal 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;
|
||||||
63
water-api/src/routes/user.routes.ts
Normal file
63
water-api/src/routes/user.routes.ts
Normal 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;
|
||||||
0
water-api/src/services/.gitkeep
Normal file
0
water-api/src/services/.gitkeep
Normal file
252
water-api/src/services/auth.service.ts
Normal file
252
water-api/src/services/auth.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
660
water-api/src/services/bulk-upload.service.ts
Normal file
660
water-api/src/services/bulk-upload.service.ts
Normal 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' }));
|
||||||
|
}
|
||||||
296
water-api/src/services/concentrator.service.ts
Normal file
296
water-api/src/services/concentrator.service.ts
Normal 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;
|
||||||
|
}
|
||||||
341
water-api/src/services/device.service.ts
Normal file
341
water-api/src/services/device.service.ts
Normal 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;
|
||||||
|
}
|
||||||
324
water-api/src/services/gateway.service.ts
Normal file
324
water-api/src/services/gateway.service.ts
Normal 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;
|
||||||
|
}
|
||||||
317
water-api/src/services/meter.service.ts
Normal file
317
water-api/src/services/meter.service.ts
Normal 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;
|
||||||
|
}
|
||||||
308
water-api/src/services/project.service.ts
Normal file
308
water-api/src/services/project.service.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { query } from '../config/database';
|
||||||
|
import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../validators/project.validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project interface matching database schema
|
||||||
|
*/
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
area_name: string | null;
|
||||||
|
location: string | null;
|
||||||
|
status: ProjectStatusType;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project statistics interface
|
||||||
|
*/
|
||||||
|
export interface ProjectStats {
|
||||||
|
meter_count: number;
|
||||||
|
device_count: number;
|
||||||
|
concentrator_count: number;
|
||||||
|
active_meters: number;
|
||||||
|
inactive_meters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination parameters interface
|
||||||
|
*/
|
||||||
|
export interface PaginationParams {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter parameters for projects
|
||||||
|
*/
|
||||||
|
export interface ProjectFilters {
|
||||||
|
status?: ProjectStatusType;
|
||||||
|
area_name?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated result interface
|
||||||
|
*/
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all projects with optional filtering and pagination
|
||||||
|
* @param filters - Optional filters for status and area_name
|
||||||
|
* @param pagination - Optional pagination parameters
|
||||||
|
* @returns Paginated list of projects
|
||||||
|
*/
|
||||||
|
export async function getAll(
|
||||||
|
filters?: ProjectFilters,
|
||||||
|
pagination?: PaginationParams
|
||||||
|
): Promise<PaginatedResult<Project>> {
|
||||||
|
const page = pagination?.page || 1;
|
||||||
|
const pageSize = pagination?.pageSize || 10;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// Build WHERE clause dynamically
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters?.status) {
|
||||||
|
conditions.push(`status = $${paramIndex}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.area_name) {
|
||||||
|
conditions.push(`area_name ILIKE $${paramIndex}`);
|
||||||
|
params.push(`%${filters.area_name}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.search) {
|
||||||
|
conditions.push(`(name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM projects ${whereClause}`;
|
||||||
|
const countResult = await query<{ total: string }>(countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||||
|
|
||||||
|
// Get paginated data
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT id, name, description, area_name, location, status, created_by, created_at, updated_at
|
||||||
|
FROM projects
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
params.push(pageSize, offset);
|
||||||
|
|
||||||
|
const result = await query<Project>(dataQuery, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single project by ID
|
||||||
|
* @param id - Project UUID
|
||||||
|
* @returns Project or null if not found
|
||||||
|
*/
|
||||||
|
export async function getById(id: string): Promise<Project | null> {
|
||||||
|
const result = await query<Project>(
|
||||||
|
`SELECT id, name, description, area_name, location, status, created_by, created_at, updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project
|
||||||
|
* @param data - Project data
|
||||||
|
* @param userId - ID of the user creating the project
|
||||||
|
* @returns Created project
|
||||||
|
*/
|
||||||
|
export async function create(data: CreateProjectInput, userId: string): Promise<Project> {
|
||||||
|
const result = await query<Project>(
|
||||||
|
`INSERT INTO projects (name, description, area_name, location, status, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, name, description, area_name, location, status, created_by, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
data.name,
|
||||||
|
data.description || null,
|
||||||
|
data.area_name || null,
|
||||||
|
data.location || null,
|
||||||
|
data.status || 'ACTIVE',
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing project
|
||||||
|
* @param id - Project UUID
|
||||||
|
* @param data - Updated project data
|
||||||
|
* @returns Updated project or null if not found
|
||||||
|
*/
|
||||||
|
export async function update(id: string, data: UpdateProjectInput): Promise<Project | null> {
|
||||||
|
// Build SET clause dynamically based on provided fields
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
updates.push(`name = $${paramIndex}`);
|
||||||
|
params.push(data.name);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updates.push(`description = $${paramIndex}`);
|
||||||
|
params.push(data.description);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.area_name !== undefined) {
|
||||||
|
updates.push(`area_name = $${paramIndex}`);
|
||||||
|
params.push(data.area_name);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.location !== undefined) {
|
||||||
|
updates.push(`location = $${paramIndex}`);
|
||||||
|
params.push(data.location);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status !== undefined) {
|
||||||
|
updates.push(`status = $${paramIndex}`);
|
||||||
|
params.push(data.status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update the updated_at timestamp
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
if (updates.length === 1) {
|
||||||
|
// Only updated_at was added, no actual data to update
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const result = await query<Project>(
|
||||||
|
`UPDATE projects
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING id, name, description, area_name, location, status, created_by, created_at, updated_at`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a project by ID
|
||||||
|
* Checks for dependent meters/concentrators before deletion
|
||||||
|
* @param id - Project UUID
|
||||||
|
* @returns True if deleted, throws error if has dependencies
|
||||||
|
*/
|
||||||
|
export async function deleteProject(id: string): Promise<boolean> {
|
||||||
|
// Check for dependent meters
|
||||||
|
const meterCheck = await query<{ count: string }>(
|
||||||
|
'SELECT COUNT(*) as count FROM meters WHERE project_id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const meterCount = parseInt(meterCheck.rows[0]?.count || '0', 10);
|
||||||
|
|
||||||
|
if (meterCount > 0) {
|
||||||
|
throw new Error(`Cannot delete project: ${meterCount} meter(s) are associated with this project`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dependent concentrators
|
||||||
|
const concentratorCheck = await query<{ count: string }>(
|
||||||
|
'SELECT COUNT(*) as count FROM concentrators WHERE project_id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const concentratorCount = parseInt(concentratorCheck.rows[0]?.count || '0', 10);
|
||||||
|
|
||||||
|
if (concentratorCount > 0) {
|
||||||
|
throw new Error(`Cannot delete project: ${concentratorCount} concentrator(s) are associated with this project`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query('DELETE FROM projects WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
return (result.rowCount || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project statistics
|
||||||
|
* @param id - Project UUID
|
||||||
|
* @returns Project statistics including meter count, device count, etc.
|
||||||
|
*/
|
||||||
|
export async function getStats(id: string): Promise<ProjectStats | null> {
|
||||||
|
// Verify project exists
|
||||||
|
const project = await getById(id);
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get meter counts
|
||||||
|
const meterStats = await query<{ total: string; active: string; inactive: string }>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'ACTIVE' OR status = 'active') as active,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'INACTIVE' OR status = 'inactive') as inactive
|
||||||
|
FROM meters
|
||||||
|
WHERE project_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get device count (devices linked to meters in this project)
|
||||||
|
const deviceStats = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(DISTINCT device_id) as count
|
||||||
|
FROM meters
|
||||||
|
WHERE project_id = $1 AND device_id IS NOT NULL`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get concentrator count
|
||||||
|
const concentratorStats = await query<{ count: string }>(
|
||||||
|
'SELECT COUNT(*) as count FROM concentrators WHERE project_id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meter_count: parseInt(meterStats.rows[0]?.total || '0', 10),
|
||||||
|
active_meters: parseInt(meterStats.rows[0]?.active || '0', 10),
|
||||||
|
inactive_meters: parseInt(meterStats.rows[0]?.inactive || '0', 10),
|
||||||
|
device_count: parseInt(deviceStats.rows[0]?.count || '0', 10),
|
||||||
|
concentrator_count: parseInt(concentratorStats.rows[0]?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
288
water-api/src/services/reading.service.ts
Normal file
288
water-api/src/services/reading.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
226
water-api/src/services/role.service.ts
Normal file
226
water-api/src/services/role.service.ts
Normal 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;
|
||||||
|
}
|
||||||
43
water-api/src/services/tts/index.ts
Normal file
43
water-api/src/services/tts/index.ts
Normal 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';
|
||||||
491
water-api/src/services/tts/payloadDecoder.service.ts
Normal file
491
water-api/src/services/tts/payloadDecoder.service.ts
Normal 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,
|
||||||
|
};
|
||||||
518
water-api/src/services/tts/ttsApi.service.ts
Normal file
518
water-api/src/services/tts/ttsApi.service.ts
Normal 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,
|
||||||
|
};
|
||||||
545
water-api/src/services/tts/ttsWebhook.service.ts
Normal file
545
water-api/src/services/tts/ttsWebhook.service.ts
Normal 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,
|
||||||
|
};
|
||||||
436
water-api/src/services/user.service.ts
Normal file
436
water-api/src/services/user.service.ts
Normal 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;
|
||||||
|
}
|
||||||
342
water-api/src/types/index.ts
Normal file
342
water-api/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
0
water-api/src/utils/.gitkeep
Normal file
0
water-api/src/utils/.gitkeep
Normal file
137
water-api/src/utils/jwt.ts
Normal file
137
water-api/src/utils/jwt.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
39
water-api/src/utils/logger.ts
Normal file
39
water-api/src/utils/logger.ts
Normal 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;
|
||||||
43
water-api/src/utils/password.ts
Normal file
43
water-api/src/utils/password.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
0
water-api/src/validators/.gitkeep
Normal file
0
water-api/src/validators/.gitkeep
Normal file
66
water-api/src/validators/auth.validator.ts
Normal file
66
water-api/src/validators/auth.validator.ts
Normal 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);
|
||||||
132
water-api/src/validators/concentrator.validator.ts
Normal file
132
water-api/src/validators/concentrator.validator.ts
Normal 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);
|
||||||
144
water-api/src/validators/device.validator.ts
Normal file
144
water-api/src/validators/device.validator.ts
Normal 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);
|
||||||
118
water-api/src/validators/gateway.validator.ts
Normal file
118
water-api/src/validators/gateway.validator.ts
Normal 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);
|
||||||
161
water-api/src/validators/meter.validator.ts
Normal file
161
water-api/src/validators/meter.validator.ts
Normal 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);
|
||||||
118
water-api/src/validators/project.validator.ts
Normal file
118
water-api/src/validators/project.validator.ts
Normal 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);
|
||||||
141
water-api/src/validators/role.validator.ts
Normal file
141
water-api/src/validators/role.validator.ts
Normal 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);
|
||||||
222
water-api/src/validators/tts.validator.ts
Normal file
222
water-api/src/validators/tts.validator.ts
Normal 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);
|
||||||
119
water-api/src/validators/user.validator.ts
Normal file
119
water-api/src/validators/user.validator.ts
Normal 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
44
water-api/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user