Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades

Backend (water-api/):
- Crear API REST completa con Express + TypeScript
- Implementar autenticación JWT con refresh tokens
- CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles
- Agregar validación con Zod para todas las entidades
- Implementar webhooks para The Things Stack (LoRaWAN)
- Agregar endpoint de lecturas con filtros y resumen de consumo
- Implementar carga masiva de medidores via Excel (.xlsx)

Frontend:
- Crear cliente HTTP con manejo automático de JWT y refresh
- Actualizar todas las APIs para usar nuevo backend
- Agregar sistema de autenticación real (login, logout, me)
- Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores
- Agregar campo Meter ID en medidores
- Crear modal de carga masiva para medidores
- Agregar página de consumo con gráficas y filtros
- Corregir carga de proyectos independiente de datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Exteban08
2026-01-23 10:13:26 +00:00
parent 2b5735d78d
commit c81a18987f
92 changed files with 14088 additions and 1866 deletions

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

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

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

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

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

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

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

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

View File

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

View File

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

View File

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

View File

View File

View File

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

View File

@@ -0,0 +1,22 @@
import { Router } from 'express';
import { uploadMeters, downloadMeterTemplate, 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);
export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

@@ -0,0 +1,317 @@
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': 'serial_number',
'numero_de_serie': 'serial_number',
'serial_number': 'serial_number',
'meter_id': 'meter_id',
'meterid': 'meter_id',
'id_medidor': 'meter_id',
'nombre': 'name',
'name': 'name',
'concentrador': 'concentrator_serial',
'concentrator': 'concentrator_serial',
'concentrator_serial': 'concentrator_serial',
'serial_concentrador': 'concentrator_serial',
'ubicacion': 'location',
'location': 'location',
'tipo': 'type',
'type': 'type',
'estado': 'status',
'status': 'status',
'fecha_instalacion': 'installation_date',
'installation_date': 'installation_date',
'fecha_de_instalacion': '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
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: row.installation_date ? String(row.installation_date).trim() : undefined,
};
// Validate type
const validTypes = ['LORA', 'LORAWAN', 'GRANDES'];
if (!validTypes.includes(meterData.type!)) {
meterData.type = 'LORA';
}
// Validate status
const validStatuses = ['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'FAULTY', 'REPLACED'];
if (!validStatuses.includes(meterData.status!)) {
meterData.status = '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' }));
}

View File

@@ -0,0 +1,296 @@
import { pool } from '../config/database';
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
/**
* Concentrator types
*/
export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES';
/**
* Concentrator entity interface
*/
export interface Concentrator {
id: string;
serial_number: string;
name: string | null;
project_id: string;
location: string | null;
type: ConcentratorType;
status: 'online' | 'offline' | 'maintenance' | 'unknown';
ip_address: string | null;
firmware_version: string | null;
created_at: Date;
updated_at: Date;
}
/**
* Concentrator with gateway count
*/
export interface ConcentratorWithCount extends Concentrator {
gateway_count: number;
}
/**
* Filter options for concentrators
*/
export interface ConcentratorFilters {
project_id?: string;
status?: string;
type?: ConcentratorType;
}
/**
* Pagination options
*/
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
/**
* Paginated result
*/
export interface PaginatedResult<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Get all concentrators with optional filters and pagination
* @param filters - Optional filter criteria
* @param pagination - Optional pagination options
* @returns Paginated list of concentrators
*/
export async function getAll(
filters?: ConcentratorFilters,
pagination?: PaginationOptions
): Promise<PaginatedResult<Concentrator>> {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const offset = (page - 1) * limit;
const sortBy = pagination?.sortBy || 'created_at';
const sortOrder = pagination?.sortOrder || 'desc';
// Build WHERE clause
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters?.project_id) {
conditions.push(`project_id = $${paramIndex}`);
params.push(filters.project_id);
paramIndex++;
}
if (filters?.status) {
conditions.push(`status = $${paramIndex}`);
params.push(filters.status);
paramIndex++;
}
if (filters?.type) {
conditions.push(`type = $${paramIndex}`);
params.push(filters.type);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Validate sort column to prevent SQL injection
const allowedSortColumns = ['id', 'serial_number', 'name', 'status', 'created_at', 'updated_at'];
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
// Get total count
const countQuery = `SELECT COUNT(*) FROM concentrators ${whereClause}`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0].count, 10);
// Get data
const dataQuery = `
SELECT * FROM concentrators
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(limit, offset);
const dataResult = await pool.query<Concentrator>(dataQuery, params);
const totalPages = Math.ceil(total / limit);
return {
data: dataResult.rows,
pagination: {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Get a single concentrator by ID with gateway count
* @param id - Concentrator UUID
* @returns Concentrator with gateway count or null
*/
export async function getById(id: string): Promise<ConcentratorWithCount | null> {
const query = `
SELECT
c.*,
COALESCE(COUNT(g.id), 0)::int as gateway_count
FROM concentrators c
LEFT JOIN gateways g ON g.concentrator_id = c.id
WHERE c.id = $1
GROUP BY c.id
`;
const result = await pool.query<ConcentratorWithCount>(query, [id]);
return result.rows[0] || null;
}
/**
* Create a new concentrator
* @param data - Concentrator creation data
* @returns Created concentrator
*/
export async function create(data: CreateConcentratorInput): Promise<Concentrator> {
const query = `
INSERT INTO concentrators (serial_number, name, project_id, location, type, status, ip_address, firmware_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const params = [
data.serial_number,
data.name || null,
data.project_id,
data.location || null,
data.type || 'LORA',
data.status || 'ACTIVE',
data.ip_address || null,
data.firmware_version || null,
];
const result = await pool.query<Concentrator>(query, params);
return result.rows[0];
}
/**
* Update an existing concentrator
* @param id - Concentrator UUID
* @param data - Update data
* @returns Updated concentrator or null if not found
*/
export async function update(id: string, data: UpdateConcentratorInput): Promise<Concentrator | null> {
// Build SET clause dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.serial_number !== undefined) {
updates.push(`serial_number = $${paramIndex}`);
params.push(data.serial_number);
paramIndex++;
}
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.project_id !== undefined) {
updates.push(`project_id = $${paramIndex}`);
params.push(data.project_id);
paramIndex++;
}
if (data.location !== undefined) {
updates.push(`location = $${paramIndex}`);
params.push(data.location);
paramIndex++;
}
if (data.type !== undefined) {
updates.push(`type = $${paramIndex}`);
params.push(data.type);
paramIndex++;
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex}`);
params.push(data.status);
paramIndex++;
}
if (data.ip_address !== undefined) {
updates.push(`ip_address = $${paramIndex}`);
params.push(data.ip_address);
paramIndex++;
}
if (data.firmware_version !== undefined) {
updates.push(`firmware_version = $${paramIndex}`);
params.push(data.firmware_version);
paramIndex++;
}
if (updates.length === 0) {
// No updates provided, return existing record
const existing = await getById(id);
return existing;
}
updates.push(`updated_at = NOW()`);
const query = `
UPDATE concentrators
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await pool.query<Concentrator>(query, params);
return result.rows[0] || null;
}
/**
* Delete a concentrator
* Checks for dependent gateways before deletion
* @param id - Concentrator UUID
* @returns True if deleted, throws error if has dependencies
*/
export async function remove(id: string): Promise<boolean> {
// Check for dependent gateways
const gatewayCheck = await pool.query(
'SELECT COUNT(*) FROM gateways WHERE concentrator_id = $1',
[id]
);
const gatewayCount = parseInt(gatewayCheck.rows[0].count, 10);
if (gatewayCount > 0) {
throw new Error(`Cannot delete concentrator: ${gatewayCount} gateway(s) are associated with it`);
}
const result = await pool.query(
'DELETE FROM concentrators WHERE id = $1 RETURNING id',
[id]
);
return result.rowCount !== null && result.rowCount > 0;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,291 @@
import { query } from '../config/database';
/**
* Meter reading interface matching database schema
*/
export interface MeterReading {
id: string;
meter_id: string;
device_id: string | null;
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;
device_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.device_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.device_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, device_id, reading_value, reading_type,
battery_level, signal_strength, raw_payload, received_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, NOW()))
RETURNING id, meter_id, device_id, reading_value, reading_type,
battery_level, signal_strength, raw_payload, received_at, created_at`,
[
data.meter_id,
data.device_id || null,
data.reading_value,
data.reading_type || 'AUTOMATIC',
data.battery_level || null,
data.signal_strength || null,
data.raw_payload || null,
data.received_at || null,
]
);
// Update meter's last reading
await query(
`UPDATE meters
SET last_reading_value = $1, last_reading_at = $2, updated_at = NOW()
WHERE id = $3`,
[data.reading_value, result.rows[0].received_at, data.meter_id]
);
return result.rows[0];
}
/**
* Delete a reading by ID
* @param id - Reading UUID
* @returns True if deleted
*/
export async function deleteReading(id: string): Promise<boolean> {
const result = await query('DELETE FROM meter_readings WHERE id = $1', [id]);
return (result.rowCount || 0) > 0;
}
/**
* Get consumption summary by project
* @param projectId - Optional project ID to filter
* @returns Summary statistics
*/
export async function getConsumptionSummary(projectId?: string): Promise<{
totalReadings: number;
totalMeters: number;
avgReading: number;
lastReadingDate: Date | null;
}> {
const params: unknown[] = [];
let whereClause = '';
if (projectId) {
whereClause = 'WHERE c.project_id = $1';
params.push(projectId);
}
const result = await query<{
total_readings: string;
total_meters: string;
avg_reading: string;
last_reading: Date | null;
}>(
`SELECT
COUNT(mr.id) as total_readings,
COUNT(DISTINCT mr.meter_id) as total_meters,
COALESCE(AVG(mr.reading_value), 0) as avg_reading,
MAX(mr.received_at) as last_reading
FROM meter_readings mr
JOIN meters m ON mr.meter_id = m.id
JOIN concentrators c ON m.concentrator_id = c.id
${whereClause}`,
params
);
const row = result.rows[0];
return {
totalReadings: parseInt(row?.total_readings || '0', 10),
totalMeters: parseInt(row?.total_meters || '0', 10),
avgReading: parseFloat(row?.avg_reading || '0'),
lastReadingDate: row?.last_reading || null,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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