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:
0
water-api/src/config/.gitkeep
Normal file
0
water-api/src/config/.gitkeep
Normal file
113
water-api/src/config/database.ts
Normal file
113
water-api/src/config/database.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
|
||||
import config from './index';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const pool = new Pool({
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
database: config.database.database,
|
||||
ssl: config.database.ssl ? { rejectUnauthorized: false } : false,
|
||||
max: config.database.maxConnections,
|
||||
idleTimeoutMillis: config.database.idleTimeoutMs,
|
||||
connectionTimeoutMillis: config.database.connectionTimeoutMs,
|
||||
});
|
||||
|
||||
pool.on('connect', () => {
|
||||
logger.debug('New client connected to the database pool');
|
||||
});
|
||||
|
||||
pool.on('error', (err: Error) => {
|
||||
logger.error('Unexpected error on idle database client', err);
|
||||
});
|
||||
|
||||
pool.on('remove', () => {
|
||||
logger.debug('Client removed from the database pool');
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute a query on the database pool
|
||||
* @param text - SQL query string
|
||||
* @param params - Query parameters
|
||||
* @returns Query result
|
||||
*/
|
||||
export const query = async <T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> => {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await pool.query<T>(text, params);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.debug(`Query executed in ${duration}ms`, {
|
||||
query: text,
|
||||
rows: result.rowCount,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Database query error', {
|
||||
query: text,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a client from the pool for transactions
|
||||
* @returns Pool client
|
||||
*/
|
||||
export const getClient = async (): Promise<PoolClient> => {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
logger.debug('Database client acquired from pool');
|
||||
return client;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get database client', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Test database connectivity
|
||||
* @returns True if connection is successful, false otherwise
|
||||
*/
|
||||
export const testConnection = async (): Promise<boolean> => {
|
||||
try {
|
||||
const result = await pool.query('SELECT NOW()');
|
||||
logger.info('Database connection successful', {
|
||||
serverTime: result.rows[0]?.now,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Database connection failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close all pool connections (for graceful shutdown)
|
||||
*/
|
||||
export const closePool = async (): Promise<void> => {
|
||||
try {
|
||||
await pool.end();
|
||||
logger.info('Database pool closed');
|
||||
} catch (error) {
|
||||
logger.error('Error closing database pool', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export { pool };
|
||||
|
||||
export default pool;
|
||||
128
water-api/src/config/index.ts
Normal file
128
water-api/src/config/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
interface ServerConfig {
|
||||
port: number;
|
||||
env: string;
|
||||
isProduction: boolean;
|
||||
isDevelopment: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
ssl: boolean;
|
||||
maxConnections: number;
|
||||
idleTimeoutMs: number;
|
||||
connectionTimeoutMs: number;
|
||||
}
|
||||
|
||||
interface JwtConfig {
|
||||
accessTokenSecret: string;
|
||||
refreshTokenSecret: string;
|
||||
accessTokenExpiresIn: string;
|
||||
refreshTokenExpiresIn: string;
|
||||
}
|
||||
|
||||
interface CorsConfig {
|
||||
origin: string | string[];
|
||||
credentials: boolean;
|
||||
methods: string[];
|
||||
allowedHeaders: string[];
|
||||
}
|
||||
|
||||
interface TtsConfig {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
applicationId: string;
|
||||
webhookSecret: string;
|
||||
requireWebhookVerification: boolean;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
server: ServerConfig;
|
||||
database: DatabaseConfig;
|
||||
jwt: JwtConfig;
|
||||
cors: CorsConfig;
|
||||
tts: TtsConfig;
|
||||
}
|
||||
|
||||
const requiredEnvVars = [
|
||||
'DB_HOST',
|
||||
'DB_USER',
|
||||
'DB_PASSWORD',
|
||||
'DB_NAME',
|
||||
'JWT_ACCESS_SECRET',
|
||||
'JWT_REFRESH_SECRET',
|
||||
];
|
||||
|
||||
const validateEnvVars = (): void => {
|
||||
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missing.join(', ')}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const parseOrigin = (origin: string | undefined): string | string[] => {
|
||||
if (!origin) return '*';
|
||||
if (origin.includes(',')) {
|
||||
return origin.split(',').map((o) => o.trim());
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
|
||||
const getConfig = (): Config => {
|
||||
validateEnvVars();
|
||||
|
||||
return {
|
||||
server: {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
},
|
||||
database: {
|
||||
host: process.env.DB_HOST!,
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
user: process.env.DB_USER!,
|
||||
password: process.env.DB_PASSWORD!,
|
||||
database: process.env.DB_NAME!,
|
||||
ssl: process.env.DB_SSL === 'true',
|
||||
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20', 10),
|
||||
idleTimeoutMs: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||
connectionTimeoutMs: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10),
|
||||
},
|
||||
jwt: {
|
||||
accessTokenSecret: process.env.JWT_ACCESS_SECRET!,
|
||||
refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
|
||||
accessTokenExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
|
||||
refreshTokenExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
},
|
||||
cors: {
|
||||
origin: parseOrigin(process.env.CORS_ORIGIN),
|
||||
credentials: process.env.CORS_CREDENTIALS === 'true',
|
||||
methods: (process.env.CORS_METHODS || 'GET,POST,PUT,DELETE,PATCH,OPTIONS').split(','),
|
||||
allowedHeaders: (process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization').split(','),
|
||||
},
|
||||
tts: {
|
||||
enabled: process.env.TTS_ENABLED === 'true',
|
||||
apiKey: process.env.TTS_API_KEY || '',
|
||||
apiUrl: process.env.TTS_API_URL || '',
|
||||
applicationId: process.env.TTS_APPLICATION_ID || '',
|
||||
webhookSecret: process.env.TTS_WEBHOOK_SECRET || '',
|
||||
requireWebhookVerification: process.env.TTS_REQUIRE_WEBHOOK_VERIFICATION !== 'false',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const config = getConfig();
|
||||
|
||||
export default config;
|
||||
0
water-api/src/controllers/.gitkeep
Normal file
0
water-api/src/controllers/.gitkeep
Normal file
138
water-api/src/controllers/auth.controller.ts
Normal file
138
water-api/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import * as authService from '../services/auth.service';
|
||||
import { LoginInput, RefreshInput } from '../validators/auth.validator';
|
||||
|
||||
/**
|
||||
* POST /auth/login
|
||||
* Authenticate user with email and password
|
||||
* Returns access token and refresh token
|
||||
*/
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email, password } = req.body as LoginInput;
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
user: result.user,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Login failed';
|
||||
|
||||
// Use 401 for authentication failures
|
||||
if (message === 'Invalid email or password') {
|
||||
res.status(401).json({ success: false, error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
* Generate new access token using refresh token
|
||||
* Returns new access token
|
||||
*/
|
||||
export async function refresh(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { refreshToken } = req.body as RefreshInput;
|
||||
|
||||
const result = await authService.refresh(refreshToken);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Token refresh failed';
|
||||
|
||||
// Use 401 for invalid/expired tokens
|
||||
if (
|
||||
message === 'Invalid refresh token' ||
|
||||
message === 'Refresh token not found or revoked' ||
|
||||
message === 'Refresh token expired'
|
||||
) {
|
||||
res.status(401).json({ success: false, error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Invalidate the refresh token
|
||||
* Requires authentication
|
||||
*/
|
||||
export async function logout(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ success: false, error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { refreshToken } = req.body as RefreshInput;
|
||||
|
||||
if (refreshToken) {
|
||||
await authService.logout(userId, refreshToken);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logout successful',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
* Get authenticated user's profile
|
||||
* Requires authentication
|
||||
*/
|
||||
export async function getMe(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ success: false, error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await authService.getMe(userId);
|
||||
|
||||
// Transform avatarUrl to avatar_url for frontend compatibility
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
role: profile.role,
|
||||
avatar_url: profile.avatarUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get profile';
|
||||
|
||||
if (message === 'User not found') {
|
||||
res.status(404).json({ success: false, error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
82
water-api/src/controllers/bulk-upload.controller.ts
Normal file
82
water-api/src/controllers/bulk-upload.controller.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
202
water-api/src/controllers/concentrator.controller.ts
Normal file
202
water-api/src/controllers/concentrator.controller.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as concentratorService from '../services/concentrator.service';
|
||||
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
|
||||
|
||||
/**
|
||||
* GET /concentrators
|
||||
* Get all concentrators with optional filters and pagination
|
||||
* Query params: project_id, status, page, limit, sortBy, sortOrder
|
||||
*/
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { project_id, status, page, limit, sortBy, sortOrder } = req.query;
|
||||
|
||||
const filters: concentratorService.ConcentratorFilters = {};
|
||||
if (project_id) filters.project_id = project_id as string;
|
||||
if (status) filters.status = status as string;
|
||||
|
||||
const pagination: concentratorService.PaginationOptions = {
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 10,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
const result = await concentratorService.getAll(filters, pagination);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching concentrators:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch concentrators',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /concentrators/:id
|
||||
* Get a single concentrator by ID with gateway count
|
||||
*/
|
||||
export async function getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const concentrator = await concentratorService.getById(id);
|
||||
|
||||
if (!concentrator) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Concentrator not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: concentrator,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching concentrator:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch concentrator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /concentrators
|
||||
* Create a new concentrator
|
||||
*/
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = req.body as CreateConcentratorInput;
|
||||
|
||||
const concentrator = await concentratorService.create(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: concentrator,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating concentrator:', error);
|
||||
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('duplicate')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A concentrator with this serial number already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for foreign key violation (invalid project_id)
|
||||
if (error instanceof Error && error.message.includes('foreign key')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid project_id: Project does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create concentrator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /concentrators/:id
|
||||
* Update an existing concentrator
|
||||
*/
|
||||
export async function update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = req.body as UpdateConcentratorInput;
|
||||
|
||||
const concentrator = await concentratorService.update(id, data);
|
||||
|
||||
if (!concentrator) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Concentrator not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: concentrator,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating concentrator:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('duplicate')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A concentrator with this serial number already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('foreign key')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid project_id: Project does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update concentrator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /concentrators/:id
|
||||
* Delete a concentrator
|
||||
*/
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await concentratorService.remove(id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Concentrator not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { message: 'Concentrator deleted successfully' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting concentrator:', error);
|
||||
|
||||
// Check for dependency error
|
||||
if (error instanceof Error && error.message.includes('Cannot delete')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete concentrator',
|
||||
});
|
||||
}
|
||||
}
|
||||
225
water-api/src/controllers/device.controller.ts
Normal file
225
water-api/src/controllers/device.controller.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as deviceService from '../services/device.service';
|
||||
import { CreateDeviceInput, UpdateDeviceInput } from '../validators/device.validator';
|
||||
|
||||
/**
|
||||
* GET /devices
|
||||
* Get all devices with optional filters and pagination
|
||||
* Query params: project_id, gateway_id, status, device_type, page, limit, sortBy, sortOrder
|
||||
*/
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { project_id, gateway_id, status, device_type, page, limit, sortBy, sortOrder } = req.query;
|
||||
|
||||
const filters: deviceService.DeviceFilters = {};
|
||||
if (project_id) filters.project_id = project_id as string;
|
||||
if (gateway_id) filters.gateway_id = gateway_id as string;
|
||||
if (status) filters.status = status as string;
|
||||
if (device_type) filters.device_type = device_type as string;
|
||||
|
||||
const pagination: deviceService.PaginationOptions = {
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 10,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
const result = await deviceService.getAll(filters, pagination);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch devices',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /devices/:id
|
||||
* Get a single device by ID with meter info
|
||||
*/
|
||||
export async function getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const device = await deviceService.getById(id);
|
||||
|
||||
if (!device) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: device,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching device:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch device',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /devices/dev-eui/:devEui
|
||||
* Get a device by DevEUI
|
||||
*/
|
||||
export async function getByDevEui(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { devEui } = req.params;
|
||||
|
||||
const device = await deviceService.getByDevEui(devEui);
|
||||
|
||||
if (!device) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: device,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching device by DevEUI:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch device',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /devices
|
||||
* Create a new device
|
||||
*/
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = req.body as CreateDeviceInput;
|
||||
|
||||
const device = await deviceService.create(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: device,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating device:', error);
|
||||
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('duplicate')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A device with this DevEUI already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for foreign key violation
|
||||
if (error instanceof Error && error.message.includes('foreign key')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid project_id or gateway_id: Reference does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create device',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /devices/:id
|
||||
* Update an existing device
|
||||
*/
|
||||
export async function update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = req.body as UpdateDeviceInput;
|
||||
|
||||
const device = await deviceService.update(id, data);
|
||||
|
||||
if (!device) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: device,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating device:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('duplicate')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A device with this DevEUI already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('foreign key')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid project_id or gateway_id: Reference does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update device',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /devices/:id
|
||||
* Delete a device
|
||||
*/
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await deviceService.remove(id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { message: 'Device deleted successfully' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting device:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete device',
|
||||
});
|
||||
}
|
||||
}
|
||||
237
water-api/src/controllers/gateway.controller.ts
Normal file
237
water-api/src/controllers/gateway.controller.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as gatewayService from '../services/gateway.service';
|
||||
import { CreateGatewayInput, UpdateGatewayInput } from '../validators/gateway.validator';
|
||||
|
||||
/**
|
||||
* GET /gateways
|
||||
* Get all gateways with optional filters and pagination
|
||||
* Query params: project_id, concentrator_id, status, page, limit, sortBy, sortOrder
|
||||
*/
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { project_id, concentrator_id, status, page, limit, sortBy, sortOrder } = req.query;
|
||||
|
||||
const filters: gatewayService.GatewayFilters = {};
|
||||
if (project_id) filters.project_id = project_id as string;
|
||||
if (concentrator_id) filters.concentrator_id = concentrator_id as string;
|
||||
if (status) filters.status = status as string;
|
||||
|
||||
const pagination: gatewayService.PaginationOptions = {
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 10,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
const result = await gatewayService.getAll(filters, pagination);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gateways:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch gateways',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /gateways/:id
|
||||
* Get a single gateway by ID with device count
|
||||
*/
|
||||
export async function getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const gateway = await gatewayService.getById(id);
|
||||
|
||||
if (!gateway) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Gateway not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: gateway,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gateway:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch gateway',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /gateways/:id/devices
|
||||
* Get all devices for a specific gateway
|
||||
*/
|
||||
export async function getDevices(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// First check if gateway exists
|
||||
const gateway = await gatewayService.getById(id);
|
||||
|
||||
if (!gateway) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Gateway not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await gatewayService.getDevices(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: devices,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gateway devices:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch gateway devices',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gateways
|
||||
* Create a new gateway
|
||||
*/
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = req.body as CreateGatewayInput;
|
||||
|
||||
const gateway = await gatewayService.create(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: gateway,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating gateway:', error);
|
||||
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('duplicate')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A gateway with this gateway_id already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for foreign key violation
|
||||
if (error instanceof Error && error.message.includes('foreign key')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid project_id or concentrator_id: Reference does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create gateway',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /gateways/:id
|
||||
* Update an existing gateway
|
||||
*/
|
||||
export async function update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = req.body as UpdateGatewayInput;
|
||||
|
||||
const gateway = await gatewayService.update(id, data);
|
||||
|
||||
if (!gateway) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Gateway not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: gateway,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating gateway:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('duplicate')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A gateway with this gateway_id already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('foreign key')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid project_id or concentrator_id: Reference does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update gateway',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /gateways/:id
|
||||
* Delete a gateway
|
||||
*/
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await gatewayService.remove(id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Gateway not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { message: 'Gateway deleted successfully' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting gateway:', error);
|
||||
|
||||
// Check for dependency error
|
||||
if (error instanceof Error && error.message.includes('Cannot delete')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete gateway',
|
||||
});
|
||||
}
|
||||
}
|
||||
284
water-api/src/controllers/meter.controller.ts
Normal file
284
water-api/src/controllers/meter.controller.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import * as meterService from '../services/meter.service';
|
||||
import * as readingService from '../services/reading.service';
|
||||
|
||||
/**
|
||||
* GET /meters
|
||||
* List all meters with pagination and optional filtering
|
||||
* Query params: page, pageSize, concentrator_id, project_id, status, type, search
|
||||
*/
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
227
water-api/src/controllers/project.controller.ts
Normal file
227
water-api/src/controllers/project.controller.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import * as projectService from '../services/project.service';
|
||||
import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../validators/project.validator';
|
||||
|
||||
/**
|
||||
* GET /projects
|
||||
* List all projects with pagination and optional filtering
|
||||
* Query params: page, pageSize, status, area_name, search
|
||||
*/
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
|
||||
|
||||
const filters: projectService.ProjectFilters = {};
|
||||
|
||||
if (req.query.status) {
|
||||
filters.status = req.query.status as ProjectStatusType;
|
||||
}
|
||||
|
||||
if (req.query.area_name) {
|
||||
filters.area_name = req.query.area_name as string;
|
||||
}
|
||||
|
||||
if (req.query.search) {
|
||||
filters.search = req.query.search as string;
|
||||
}
|
||||
|
||||
const result = await projectService.getAll(filters, { page, pageSize });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch projects',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /projects/:id
|
||||
* Get a single project by ID
|
||||
*/
|
||||
export async function getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const project = await projectService.getById(id);
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Project not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching project:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /projects
|
||||
* Create a new project
|
||||
* Requires authentication
|
||||
*/
|
||||
export async function create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body as CreateProjectInput;
|
||||
|
||||
const project = await projectService.create(data, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /projects/:id
|
||||
* Update an existing project
|
||||
* Requires authentication
|
||||
*/
|
||||
export async function update(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = req.body as UpdateProjectInput;
|
||||
|
||||
const project = await projectService.update(id, data);
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Project not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /projects/:id
|
||||
* Delete a project
|
||||
* Requires admin role
|
||||
*/
|
||||
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// First check if project exists
|
||||
const project = await projectService.getById(id);
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Project not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await projectService.deleteProject(id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete project',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { message: 'Project deleted successfully' },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete project';
|
||||
|
||||
// Handle dependency error
|
||||
if (message.includes('Cannot delete project')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Error deleting project:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /projects/:id/stats
|
||||
* Get project statistics
|
||||
*/
|
||||
export async function getStats(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const stats = await projectService.getStats(id);
|
||||
|
||||
if (!stats) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Project not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching project stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch project statistics',
|
||||
});
|
||||
}
|
||||
}
|
||||
158
water-api/src/controllers/reading.controller.ts
Normal file
158
water-api/src/controllers/reading.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as readingService from '../services/reading.service';
|
||||
|
||||
/**
|
||||
* GET /readings
|
||||
* List all readings with pagination and filtering
|
||||
*/
|
||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
page = '1',
|
||||
pageSize = '50',
|
||||
meter_id,
|
||||
concentrator_id,
|
||||
project_id,
|
||||
start_date,
|
||||
end_date,
|
||||
reading_type,
|
||||
} = req.query;
|
||||
|
||||
const filters: readingService.ReadingFilters = {};
|
||||
if (meter_id) filters.meter_id = meter_id as string;
|
||||
if (concentrator_id) filters.concentrator_id = concentrator_id as string;
|
||||
if (project_id) filters.project_id = project_id as string;
|
||||
if (start_date) filters.start_date = start_date as string;
|
||||
if (end_date) filters.end_date = end_date as string;
|
||||
if (reading_type) filters.reading_type = reading_type as string;
|
||||
|
||||
const pagination = {
|
||||
page: parseInt(page as string, 10),
|
||||
pageSize: Math.min(parseInt(pageSize as string, 10), 100), // Max 100 per page
|
||||
};
|
||||
|
||||
const result = await readingService.getAll(filters, pagination);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching readings:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /readings/:id
|
||||
* Get a single reading by ID
|
||||
*/
|
||||
export async function getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const reading = await readingService.getById(id);
|
||||
|
||||
if (!reading) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Reading not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: reading,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching reading:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /readings
|
||||
* Create a new reading
|
||||
*/
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = req.body as readingService.CreateReadingInput;
|
||||
|
||||
const reading = await readingService.create(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: reading,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating reading:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /readings/:id
|
||||
* Delete a reading
|
||||
*/
|
||||
export async function deleteReading(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await readingService.deleteReading(id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Reading not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { message: 'Reading deleted successfully' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting reading:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /readings/summary
|
||||
* Get consumption summary statistics
|
||||
*/
|
||||
export async function getSummary(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
|
||||
const summary = await readingService.getConsumptionSummary(
|
||||
project_id as string | undefined
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: summary,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching summary:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
222
water-api/src/controllers/role.controller.ts
Normal file
222
water-api/src/controllers/role.controller.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import * as roleService from '../services/role.service';
|
||||
import { CreateRoleInput, UpdateRoleInput } from '../validators/role.validator';
|
||||
|
||||
/**
|
||||
* GET /roles
|
||||
* List all roles (all authenticated users)
|
||||
*/
|
||||
export async function getAllRoles(
|
||||
_req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roles = await roleService.getAll();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Roles retrieved successfully',
|
||||
data: roles,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to retrieve roles';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /roles/:id
|
||||
* Get a single role by ID with user count (all authenticated users)
|
||||
*/
|
||||
export async function getRoleById(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid role ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const role = await roleService.getById(roleId);
|
||||
|
||||
if (!role) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Role not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Role retrieved successfully',
|
||||
data: role,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to retrieve role';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /roles
|
||||
* Create a new role (admin only)
|
||||
*/
|
||||
export async function createRole(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const data = req.body as CreateRoleInput;
|
||||
|
||||
const role = await roleService.create({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
permissions: data.permissions as Record<string, unknown> | undefined,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Role created successfully',
|
||||
data: role,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create role';
|
||||
|
||||
if (message === 'Role name already exists') {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /roles/:id
|
||||
* Update a role (admin only)
|
||||
*/
|
||||
export async function updateRole(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid role ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body as UpdateRoleInput;
|
||||
|
||||
const role = await roleService.update(roleId, {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
permissions: data.permissions as Record<string, unknown> | undefined,
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Role not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Role updated successfully',
|
||||
data: role,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update role';
|
||||
|
||||
if (message === 'Role name already exists') {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /roles/:id
|
||||
* Delete a role (admin only, only if no users assigned)
|
||||
*/
|
||||
export async function deleteRole(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid role ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await roleService.deleteRole(roleId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Role not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Role deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete role';
|
||||
|
||||
// Handle case where users are assigned to the role
|
||||
if (message.includes('Cannot delete role')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
194
water-api/src/controllers/tts.controller.ts
Normal file
194
water-api/src/controllers/tts.controller.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Request, Response } from 'express';
|
||||
import logger from '../utils/logger';
|
||||
import * as ttsWebhookService from '../services/tts/ttsWebhook.service';
|
||||
import {
|
||||
TtsUplinkPayload,
|
||||
TtsJoinPayload,
|
||||
TtsDownlinkAckPayload,
|
||||
} from '../validators/tts.validator';
|
||||
|
||||
/**
|
||||
* Extended request interface for TTS webhooks
|
||||
*/
|
||||
export interface TtsWebhookRequest extends Request {
|
||||
ttsVerified?: boolean;
|
||||
ttsApiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/tts/uplink
|
||||
* Handle uplink webhook from The Things Stack
|
||||
*
|
||||
* This endpoint receives uplink messages when devices send data.
|
||||
* The payload is validated, logged, decoded, and used to create meter readings.
|
||||
*/
|
||||
export async function handleUplink(req: TtsWebhookRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const payload = req.body as TtsUplinkPayload;
|
||||
|
||||
logger.info('Received TTS uplink webhook', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
deviceId: payload.end_device_ids.device_id,
|
||||
fPort: payload.uplink_message.f_port,
|
||||
verified: req.ttsVerified,
|
||||
});
|
||||
|
||||
const result = await ttsWebhookService.processUplink(payload);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Uplink processed successfully',
|
||||
data: {
|
||||
logId: result.logId,
|
||||
deviceId: result.deviceId,
|
||||
meterId: result.meterId,
|
||||
readingId: result.readingId,
|
||||
readingValue: result.decodedPayload?.readingValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// We still return 200 to TTS to prevent retries for known issues
|
||||
// (device not found, decoding failed, etc.)
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
message: result.error || 'Failed to process uplink',
|
||||
data: {
|
||||
logId: result.logId,
|
||||
deviceId: result.deviceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error handling TTS uplink webhook', { error: errorMessage });
|
||||
|
||||
// Return 500 to trigger TTS retry mechanism for unexpected errors
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/tts/join
|
||||
* Handle join webhook from The Things Stack
|
||||
*
|
||||
* This endpoint receives join accept messages when devices join the network.
|
||||
* Updates device status to 'JOINED'.
|
||||
*/
|
||||
export async function handleJoin(req: TtsWebhookRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const payload = req.body as TtsJoinPayload;
|
||||
|
||||
logger.info('Received TTS join webhook', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
deviceId: payload.end_device_ids.device_id,
|
||||
verified: req.ttsVerified,
|
||||
});
|
||||
|
||||
const result = await ttsWebhookService.processJoin(payload);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Join event processed successfully',
|
||||
data: {
|
||||
deviceId: result.deviceId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return 200 even on failure to prevent unnecessary retries
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
message: result.error || 'Failed to process join event',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error handling TTS join webhook', { error: errorMessage });
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/tts/downlink/ack
|
||||
* Handle downlink acknowledgment webhook from The Things Stack
|
||||
*
|
||||
* This endpoint receives confirmations when downlink messages are
|
||||
* acknowledged by devices, sent, failed, or queued.
|
||||
*/
|
||||
export async function handleDownlinkAck(req: TtsWebhookRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const payload = req.body as TtsDownlinkAckPayload;
|
||||
|
||||
// Determine event type for logging
|
||||
let eventType = 'ack';
|
||||
if (payload.downlink_sent) eventType = 'sent';
|
||||
if (payload.downlink_failed) eventType = 'failed';
|
||||
if (payload.downlink_queued) eventType = 'queued';
|
||||
|
||||
logger.info('Received TTS downlink webhook', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
deviceId: payload.end_device_ids.device_id,
|
||||
eventType,
|
||||
verified: req.ttsVerified,
|
||||
});
|
||||
|
||||
const result = await ttsWebhookService.processDownlinkAck(payload);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Downlink event processed successfully',
|
||||
data: {
|
||||
logId: result.logId,
|
||||
deviceId: result.deviceId,
|
||||
eventType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
message: result.error || 'Failed to process downlink event',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error handling TTS downlink webhook', { error: errorMessage });
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/webhooks/tts/health
|
||||
* Health check endpoint for TTS webhooks
|
||||
*
|
||||
* Can be used by TTS or monitoring systems to verify the webhook endpoint is available.
|
||||
*/
|
||||
export async function healthCheck(_req: Request, res: Response): Promise<void> {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'TTS webhook endpoint is healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
handleUplink,
|
||||
handleJoin,
|
||||
handleDownlinkAck,
|
||||
healthCheck,
|
||||
};
|
||||
352
water-api/src/controllers/user.controller.ts
Normal file
352
water-api/src/controllers/user.controller.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import * as userService from '../services/user.service';
|
||||
import {
|
||||
CreateUserInput,
|
||||
UpdateUserInput,
|
||||
ChangePasswordInput,
|
||||
} from '../validators/user.validator';
|
||||
|
||||
/**
|
||||
* GET /users
|
||||
* List all users (admin only)
|
||||
* Supports filtering by role_id, is_active, and search
|
||||
* Supports pagination with page, limit, sortBy, sortOrder
|
||||
*/
|
||||
export async function getAllUsers(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Parse query parameters for filters
|
||||
const filters: userService.UserFilter = {};
|
||||
|
||||
if (req.query.role_id) {
|
||||
filters.role_id = parseInt(req.query.role_id as string, 10);
|
||||
}
|
||||
|
||||
if (req.query.is_active !== undefined) {
|
||||
filters.is_active = req.query.is_active === 'true';
|
||||
}
|
||||
|
||||
if (req.query.search) {
|
||||
filters.search = req.query.search as string;
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
const pagination = {
|
||||
page: parseInt(req.query.page as string, 10) || 1,
|
||||
limit: parseInt(req.query.limit as string, 10) || 10,
|
||||
sortBy: (req.query.sortBy as string) || 'created_at',
|
||||
sortOrder: (req.query.sortOrder as 'asc' | 'desc') || 'desc',
|
||||
};
|
||||
|
||||
const result = await userService.getAll(filters, pagination);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Users retrieved successfully',
|
||||
data: result.users,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to retrieve users';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/:id
|
||||
* Get a single user by ID (admin or self)
|
||||
*/
|
||||
export async function getUserById(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid user ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is admin or requesting their own data
|
||||
const requestingUser = req.user;
|
||||
const isAdmin = requestingUser?.role === 'ADMIN';
|
||||
const isSelf = requestingUser?.id === userId.toString();
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Insufficient permissions',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getById(userId);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'User retrieved successfully',
|
||||
data: user,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to retrieve user';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /users
|
||||
* Create a new user (admin only)
|
||||
*/
|
||||
export async function createUser(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const data = req.body as CreateUserInput;
|
||||
|
||||
const user = await userService.create({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
role_id: data.role_id,
|
||||
is_active: data.is_active,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User created successfully',
|
||||
data: user,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create user';
|
||||
|
||||
if (message === 'Email already in use') {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /users/:id
|
||||
* Update a user (admin can update all fields, regular users can only update limited fields on self)
|
||||
*/
|
||||
export async function updateUser(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid user ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestingUser = req.user;
|
||||
const isAdmin = requestingUser?.role === 'ADMIN';
|
||||
const isSelf = requestingUser?.id === userId.toString();
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Insufficient permissions',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body as UpdateUserInput;
|
||||
|
||||
// Non-admin users can only update their own profile fields (not role_id or is_active)
|
||||
if (!isAdmin) {
|
||||
if (data.role_id !== undefined || data.is_active !== undefined) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'You can only update your profile information',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await userService.update(userId, data);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'User updated successfully',
|
||||
data: user,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update user';
|
||||
|
||||
if (message === 'Email already in use') {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /users/:id
|
||||
* Deactivate a user (soft delete, admin only)
|
||||
*/
|
||||
export async function deleteUser(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid user ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (req.user?.id === userId.toString()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot deactivate your own account',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await userService.deleteUser(userId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'User deactivated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to deactivate user';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /users/:id/password
|
||||
* Change user password (self only)
|
||||
*/
|
||||
export async function changePassword(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid user ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow users to change their own password
|
||||
if (req.user?.id !== userId.toString()) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'You can only change your own password',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body as ChangePasswordInput;
|
||||
|
||||
await userService.changePassword(
|
||||
userId,
|
||||
data.current_password,
|
||||
data.new_password
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Password changed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to change password';
|
||||
|
||||
if (message === 'Current password is incorrect') {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message === 'User not found') {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
97
water-api/src/index.ts
Normal file
97
water-api/src/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import routes from './routes';
|
||||
import logger from './utils/logger';
|
||||
import { testConnection } from './config/database';
|
||||
|
||||
const app: Application = express();
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// CORS configuration
|
||||
const allowedOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173')
|
||||
.split(',')
|
||||
.map(origin => origin.trim());
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
logger.warn(`CORS blocked origin: ${origin}`);
|
||||
callback(null, true); // Allow all in development
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: NODE_ENV
|
||||
});
|
||||
});
|
||||
|
||||
// Mount all API routes
|
||||
app.use('/api', routes);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Resource not found',
|
||||
error: 'NOT_FOUND'
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: NODE_ENV === 'development' ? err.message : 'Internal server error',
|
||||
error: 'INTERNAL_ERROR'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const startServer = async () => {
|
||||
try {
|
||||
// Test database connection
|
||||
await testConnection();
|
||||
logger.info('Database connection established');
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on port ${PORT} in ${NODE_ENV} mode`);
|
||||
logger.info(`Health check available at http://localhost:${PORT}/health`);
|
||||
logger.info(`API available at http://localhost:${PORT}/api`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
startServer();
|
||||
|
||||
export default app;
|
||||
0
water-api/src/middleware/.gitkeep
Normal file
0
water-api/src/middleware/.gitkeep
Normal file
84
water-api/src/middleware/auth.middleware.ts
Normal file
84
water-api/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyAccessToken } from '../utils/jwt';
|
||||
|
||||
/**
|
||||
* Extended Request interface with authenticated user
|
||||
*/
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to authenticate JWT access tokens
|
||||
* Extracts Bearer token from Authorization header, verifies it,
|
||||
* and attaches the decoded user to the request object
|
||||
*/
|
||||
export function authenticateToken(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
res.status(401).json({ error: 'Authorization header missing' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
res.status(401).json({ error: 'Invalid authorization header format' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
|
||||
try {
|
||||
const decoded = verifyAccessToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
role: decoded.role,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory for role-based access control
|
||||
* Checks if the authenticated user has one of the required roles
|
||||
* @param roles - Array of allowed roles
|
||||
*/
|
||||
export function requireRole(...roles: string[]) {
|
||||
return (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roles.includes(req.user.role)) {
|
||||
res.status(403).json({ error: 'Insufficient permissions' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
293
water-api/src/middleware/ttsWebhook.middleware.ts
Normal file
293
water-api/src/middleware/ttsWebhook.middleware.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import logger from '../utils/logger';
|
||||
import { TtsWebhookRequest } from '../controllers/tts.controller';
|
||||
|
||||
/**
|
||||
* TTS webhook verification configuration
|
||||
*/
|
||||
interface TtsWebhookConfig {
|
||||
webhookSecret: string | undefined;
|
||||
apiKey: string | undefined;
|
||||
requireVerification: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TTS webhook configuration from environment
|
||||
*/
|
||||
function getTtsWebhookConfig(): TtsWebhookConfig {
|
||||
return {
|
||||
webhookSecret: process.env.TTS_WEBHOOK_SECRET,
|
||||
apiKey: process.env.TTS_API_KEY,
|
||||
requireVerification: process.env.TTS_REQUIRE_WEBHOOK_VERIFICATION !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature for TTS webhooks
|
||||
*
|
||||
* TTS can sign webhook payloads with HMAC-SHA256.
|
||||
* The signature is provided in the X-Webhook-Signature header.
|
||||
*
|
||||
* @param payload - Raw request body
|
||||
* @param signature - Signature from header
|
||||
* @param secret - Webhook secret
|
||||
* @returns True if signature is valid
|
||||
*/
|
||||
function verifyHmacSignature(
|
||||
payload: string | Buffer,
|
||||
signature: string,
|
||||
secret: string
|
||||
): boolean {
|
||||
try {
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
const signatureBuffer = Buffer.from(signature, 'hex');
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
|
||||
|
||||
if (signatureBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
|
||||
} catch (error) {
|
||||
logger.error('Error verifying HMAC signature', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API key for TTS webhooks
|
||||
*
|
||||
* TTS can include an API key in the X-Downlink-Apikey header.
|
||||
* This is the same key used for sending downlinks.
|
||||
*
|
||||
* @param providedKey - API key from header
|
||||
* @param expectedKey - Expected API key from config
|
||||
* @returns True if API key matches
|
||||
*/
|
||||
function verifyApiKey(providedKey: string, expectedKey: string): boolean {
|
||||
try {
|
||||
const providedBuffer = Buffer.from(providedKey);
|
||||
const expectedBuffer = Buffer.from(expectedKey);
|
||||
|
||||
if (providedBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to verify TTS webhook authenticity
|
||||
*
|
||||
* Verification methods (checked in order):
|
||||
* 1. X-Downlink-Apikey header (matches TTS_API_KEY)
|
||||
* 2. X-Webhook-Signature header (HMAC-SHA256 with TTS_WEBHOOK_SECRET)
|
||||
*
|
||||
* If TTS_WEBHOOK_SECRET is not set and TTS_REQUIRE_WEBHOOK_VERIFICATION is 'false',
|
||||
* webhooks will be accepted without verification (not recommended for production).
|
||||
*/
|
||||
export function verifyTtsWebhook(
|
||||
req: TtsWebhookRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const config = getTtsWebhookConfig();
|
||||
|
||||
req.ttsVerified = false;
|
||||
|
||||
// Check X-Downlink-Apikey header first
|
||||
const apiKeyHeader = req.headers['x-downlink-apikey'] as string | undefined;
|
||||
if (apiKeyHeader && config.apiKey) {
|
||||
if (verifyApiKey(apiKeyHeader, config.apiKey)) {
|
||||
logger.debug('TTS webhook verified via API key');
|
||||
req.ttsVerified = true;
|
||||
req.ttsApiKey = apiKeyHeader;
|
||||
next();
|
||||
return;
|
||||
} else {
|
||||
logger.warn('TTS webhook API key verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-Webhook-Signature header
|
||||
const signatureHeader = req.headers['x-webhook-signature'] as string | undefined;
|
||||
if (signatureHeader && config.webhookSecret) {
|
||||
// We need the raw body for signature verification
|
||||
// This requires the raw body to be preserved in the request
|
||||
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody;
|
||||
|
||||
if (rawBody) {
|
||||
if (verifyHmacSignature(rawBody, signatureHeader, config.webhookSecret)) {
|
||||
logger.debug('TTS webhook verified via signature');
|
||||
req.ttsVerified = true;
|
||||
next();
|
||||
return;
|
||||
} else {
|
||||
logger.warn('TTS webhook signature verification failed');
|
||||
}
|
||||
} else {
|
||||
// Try with JSON stringified body as fallback
|
||||
const bodyString = JSON.stringify(req.body);
|
||||
if (verifyHmacSignature(bodyString, signatureHeader, config.webhookSecret)) {
|
||||
logger.debug('TTS webhook verified via signature (fallback)');
|
||||
req.ttsVerified = true;
|
||||
next();
|
||||
return;
|
||||
} else {
|
||||
logger.warn('TTS webhook signature verification failed (fallback)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no verification method succeeded
|
||||
if (config.requireVerification) {
|
||||
// Check if any verification method is configured
|
||||
if (!config.webhookSecret && !config.apiKey) {
|
||||
logger.warn('TTS webhook verification is required but no secrets are configured');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Webhook verification not configured',
|
||||
message: 'Server is not configured for webhook verification',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('TTS webhook verification failed', {
|
||||
hasApiKeyHeader: !!apiKeyHeader,
|
||||
hasSignatureHeader: !!signatureHeader,
|
||||
hasApiKeyConfig: !!config.apiKey,
|
||||
hasSecretConfig: !!config.webhookSecret,
|
||||
});
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or missing webhook authentication',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verification not required, proceed without verification
|
||||
logger.debug('TTS webhook proceeding without verification (verification not required)');
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to capture raw body for signature verification
|
||||
*
|
||||
* This middleware should be used BEFORE the JSON body parser
|
||||
* for routes that need signature verification.
|
||||
*
|
||||
* Usage:
|
||||
* app.use('/api/webhooks/tts', captureRawBody, express.json(), ttsRoutes);
|
||||
*/
|
||||
export function captureRawBody(
|
||||
req: TtsWebhookRequest,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
const rawBody = Buffer.concat(chunks);
|
||||
(req as unknown as { rawBody: Buffer }).rawBody = rawBody;
|
||||
|
||||
// Parse JSON body manually
|
||||
if (rawBody.length > 0) {
|
||||
try {
|
||||
req.body = JSON.parse(rawBody.toString('utf-8'));
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse webhook body as JSON');
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error('Error reading webhook body', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
next(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to extract and validate common TTS webhook fields
|
||||
*
|
||||
* Extracts device identifiers and adds them to the request for easier access.
|
||||
*/
|
||||
export function extractTtsPayloadInfo(
|
||||
req: TtsWebhookRequest,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
if (body?.end_device_ids) {
|
||||
// Add convenience properties to request
|
||||
(req as unknown as { devEui?: string }).devEui = body.end_device_ids.dev_eui;
|
||||
(req as unknown as { ttsDeviceId?: string }).ttsDeviceId = body.end_device_ids.device_id;
|
||||
(req as unknown as { applicationId?: string }).applicationId =
|
||||
body.end_device_ids.application_ids?.application_id;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extract TTS payload info', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging middleware for TTS webhooks
|
||||
*
|
||||
* Logs incoming webhook requests with relevant details.
|
||||
*/
|
||||
export function logTtsWebhook(
|
||||
req: TtsWebhookRequest,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const body = req.body;
|
||||
|
||||
logger.info('Incoming TTS webhook', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
devEui: body?.end_device_ids?.dev_eui,
|
||||
deviceId: body?.end_device_ids?.device_id,
|
||||
applicationId: body?.end_device_ids?.application_ids?.application_id,
|
||||
contentType: req.headers['content-type'],
|
||||
hasApiKey: !!req.headers['x-downlink-apikey'],
|
||||
hasSignature: !!req.headers['x-webhook-signature'],
|
||||
userAgent: req.headers['user-agent'],
|
||||
ip: req.ip || req.socket.remoteAddress,
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default {
|
||||
verifyTtsWebhook,
|
||||
captureRawBody,
|
||||
extractTtsPayloadInfo,
|
||||
logTtsWebhook,
|
||||
};
|
||||
0
water-api/src/models/.gitkeep
Normal file
0
water-api/src/models/.gitkeep
Normal file
0
water-api/src/routes/.gitkeep
Normal file
0
water-api/src/routes/.gitkeep
Normal file
41
water-api/src/routes/auth.routes.ts
Normal file
41
water-api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken } from '../middleware/auth.middleware';
|
||||
import { validateLogin, validateRefresh } from '../validators/auth.validator';
|
||||
import * as authController from '../controllers/auth.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /auth/login
|
||||
* Public endpoint - authenticate user and receive tokens
|
||||
* Body: { email: string, password: string }
|
||||
* Response: { message, accessToken, refreshToken }
|
||||
*/
|
||||
router.post('/login', validateLogin, authController.login);
|
||||
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
* Public endpoint - refresh access token using refresh token
|
||||
* Body: { refreshToken: string }
|
||||
* Response: { message, accessToken }
|
||||
*/
|
||||
router.post('/refresh', validateRefresh, authController.refresh);
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Protected endpoint - invalidate refresh token
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Body: { refreshToken: string }
|
||||
* Response: { message }
|
||||
*/
|
||||
router.post('/logout', authenticateToken, validateRefresh, authController.logout);
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
* Protected endpoint - get authenticated user profile
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Response: { user: UserProfile }
|
||||
*/
|
||||
router.get('/me', authenticateToken, authController.getMe);
|
||||
|
||||
export default router;
|
||||
22
water-api/src/routes/bulk-upload.routes.ts
Normal file
22
water-api/src/routes/bulk-upload.routes.ts
Normal 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;
|
||||
59
water-api/src/routes/concentrator.routes.ts
Normal file
59
water-api/src/routes/concentrator.routes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken } from '../middleware/auth.middleware';
|
||||
import {
|
||||
validateCreateConcentrator,
|
||||
validateUpdateConcentrator,
|
||||
} from '../validators/concentrator.validator';
|
||||
import * as concentratorController from '../controllers/concentrator.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /concentrators
|
||||
* Get all concentrators with optional filters and pagination
|
||||
* Query params: project_id, status, page, limit, sortBy, sortOrder
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/', authenticateToken, concentratorController.getAll);
|
||||
|
||||
/**
|
||||
* GET /concentrators/:id
|
||||
* Get a single concentrator by ID with gateway count
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/:id', authenticateToken, concentratorController.getById);
|
||||
|
||||
/**
|
||||
* POST /concentrators
|
||||
* Create a new concentrator
|
||||
* Body: { serial_number, name?, project_id, location?, status?, ip_address?, firmware_version? }
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticateToken,
|
||||
validateCreateConcentrator,
|
||||
concentratorController.create
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /concentrators/:id
|
||||
* Update an existing concentrator
|
||||
* Body: { serial_number?, name?, project_id?, location?, status?, ip_address?, firmware_version? }
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticateToken,
|
||||
validateUpdateConcentrator,
|
||||
concentratorController.update
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /concentrators/:id
|
||||
* Delete a concentrator (fails if gateways are associated)
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, concentratorController.remove);
|
||||
|
||||
export default router;
|
||||
66
water-api/src/routes/device.routes.ts
Normal file
66
water-api/src/routes/device.routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken } from '../middleware/auth.middleware';
|
||||
import {
|
||||
validateCreateDevice,
|
||||
validateUpdateDevice,
|
||||
} from '../validators/device.validator';
|
||||
import * as deviceController from '../controllers/device.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /devices
|
||||
* Get all devices with optional filters and pagination
|
||||
* Query params: project_id, gateway_id, status, device_type, page, limit, sortBy, sortOrder
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/', authenticateToken, deviceController.getAll);
|
||||
|
||||
/**
|
||||
* GET /devices/dev-eui/:devEui
|
||||
* Get a device by DevEUI
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/dev-eui/:devEui', authenticateToken, deviceController.getByDevEui);
|
||||
|
||||
/**
|
||||
* GET /devices/:id
|
||||
* Get a single device by ID with meter info
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/:id', authenticateToken, deviceController.getById);
|
||||
|
||||
/**
|
||||
* POST /devices
|
||||
* Create a new device
|
||||
* Body: { dev_eui, name?, device_type?, project_id, gateway_id?, status?, tts_device_id?, app_key?, join_eui? }
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticateToken,
|
||||
validateCreateDevice,
|
||||
deviceController.create
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /devices/:id
|
||||
* Update an existing device
|
||||
* Body: { dev_eui?, name?, device_type?, project_id?, gateway_id?, status?, tts_device_id?, app_key?, join_eui? }
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticateToken,
|
||||
validateUpdateDevice,
|
||||
deviceController.update
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /devices/:id
|
||||
* Delete a device (sets meter's device_id to null if associated)
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, deviceController.remove);
|
||||
|
||||
export default router;
|
||||
66
water-api/src/routes/gateway.routes.ts
Normal file
66
water-api/src/routes/gateway.routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken } from '../middleware/auth.middleware';
|
||||
import {
|
||||
validateCreateGateway,
|
||||
validateUpdateGateway,
|
||||
} from '../validators/gateway.validator';
|
||||
import * as gatewayController from '../controllers/gateway.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /gateways
|
||||
* Get all gateways with optional filters and pagination
|
||||
* Query params: project_id, concentrator_id, status, page, limit, sortBy, sortOrder
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/', authenticateToken, gatewayController.getAll);
|
||||
|
||||
/**
|
||||
* GET /gateways/:id
|
||||
* Get a single gateway by ID with device count
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/:id', authenticateToken, gatewayController.getById);
|
||||
|
||||
/**
|
||||
* GET /gateways/:id/devices
|
||||
* Get all devices for a specific gateway
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.get('/:id/devices', authenticateToken, gatewayController.getDevices);
|
||||
|
||||
/**
|
||||
* POST /gateways
|
||||
* Create a new gateway
|
||||
* Body: { gateway_id, name?, project_id, concentrator_id?, location?, status?, tts_gateway_id? }
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticateToken,
|
||||
validateCreateGateway,
|
||||
gatewayController.create
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /gateways/:id
|
||||
* Update an existing gateway
|
||||
* Body: { gateway_id?, name?, project_id?, concentrator_id?, location?, status?, tts_gateway_id? }
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticateToken,
|
||||
validateUpdateGateway,
|
||||
gatewayController.update
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /gateways/:id
|
||||
* Delete a gateway (fails if devices are associated)
|
||||
* Protected endpoint - requires authentication
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, gatewayController.remove);
|
||||
|
||||
export default router;
|
||||
133
water-api/src/routes/index.ts
Normal file
133
water-api/src/routes/index.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
// Import all route files
|
||||
import authRoutes from './auth.routes';
|
||||
import projectRoutes from './project.routes';
|
||||
import meterRoutes from './meter.routes';
|
||||
import concentratorRoutes from './concentrator.routes';
|
||||
import gatewayRoutes from './gateway.routes';
|
||||
import deviceRoutes from './device.routes';
|
||||
import userRoutes from './user.routes';
|
||||
import roleRoutes from './role.routes';
|
||||
import ttsRoutes from './tts.routes';
|
||||
import readingRoutes from './reading.routes';
|
||||
import bulkUploadRoutes from './bulk-upload.routes';
|
||||
|
||||
// Create main router
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Mount all routes with proper prefixes
|
||||
*
|
||||
* Authentication routes:
|
||||
* - POST /auth/login - Authenticate user
|
||||
* - POST /auth/refresh - Refresh access token
|
||||
* - POST /auth/logout - Logout user
|
||||
* - GET /auth/me - Get current user profile
|
||||
*/
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
/**
|
||||
* Project routes:
|
||||
* - GET /projects - List all projects
|
||||
* - GET /projects/:id - Get project by ID
|
||||
* - GET /projects/:id/stats - Get project statistics
|
||||
* - POST /projects - Create project
|
||||
* - PUT /projects/:id - Update project
|
||||
* - DELETE /projects/:id - Delete project
|
||||
*/
|
||||
router.use('/projects', projectRoutes);
|
||||
|
||||
/**
|
||||
* Meter routes:
|
||||
* - GET /meters - List all meters
|
||||
* - GET /meters/:id - Get meter by ID
|
||||
* - GET /meters/:id/readings - Get meter readings
|
||||
* - POST /meters - Create meter
|
||||
* - PUT /meters/:id - Update meter
|
||||
* - DELETE /meters/:id - Delete meter
|
||||
*/
|
||||
router.use('/meters', meterRoutes);
|
||||
|
||||
/**
|
||||
* Concentrator routes:
|
||||
* - GET /concentrators - List all concentrators
|
||||
* - GET /concentrators/:id - Get concentrator by ID
|
||||
* - POST /concentrators - Create concentrator
|
||||
* - PUT /concentrators/:id - Update concentrator
|
||||
* - DELETE /concentrators/:id - Delete concentrator
|
||||
*/
|
||||
router.use('/concentrators', concentratorRoutes);
|
||||
|
||||
/**
|
||||
* Gateway routes:
|
||||
* - GET /gateways - List all gateways
|
||||
* - GET /gateways/:id - Get gateway by ID
|
||||
* - GET /gateways/:id/devices - Get gateway devices
|
||||
* - POST /gateways - Create gateway
|
||||
* - PUT /gateways/:id - Update gateway
|
||||
* - DELETE /gateways/:id - Delete gateway
|
||||
*/
|
||||
router.use('/gateways', gatewayRoutes);
|
||||
|
||||
/**
|
||||
* Device routes:
|
||||
* - GET /devices - List all devices
|
||||
* - GET /devices/:id - Get device by ID
|
||||
* - GET /devices/dev-eui/:devEui - Get device by DevEUI
|
||||
* - POST /devices - Create device
|
||||
* - PUT /devices/:id - Update device
|
||||
* - DELETE /devices/:id - Delete device
|
||||
*/
|
||||
router.use('/devices', deviceRoutes);
|
||||
|
||||
/**
|
||||
* User routes:
|
||||
* - GET /users - List all users (admin only)
|
||||
* - GET /users/:id - Get user by ID (admin or self)
|
||||
* - POST /users - Create user (admin only)
|
||||
* - PUT /users/:id - Update user (admin or self)
|
||||
* - DELETE /users/:id - Deactivate user (admin only)
|
||||
* - PUT /users/:id/password - Change password (self only)
|
||||
*/
|
||||
router.use('/users', userRoutes);
|
||||
|
||||
/**
|
||||
* Role routes:
|
||||
* - GET /roles - List all roles
|
||||
* - GET /roles/:id - Get role by ID with user count
|
||||
* - POST /roles - Create role (admin only)
|
||||
* - PUT /roles/:id - Update role (admin only)
|
||||
* - DELETE /roles/:id - Delete role (admin only)
|
||||
*/
|
||||
router.use('/roles', roleRoutes);
|
||||
|
||||
/**
|
||||
* TTS (The Things Stack) webhook routes:
|
||||
* - GET /webhooks/tts/health - Health check
|
||||
* - POST /webhooks/tts/uplink - Handle uplink messages
|
||||
* - POST /webhooks/tts/join - Handle join events
|
||||
* - POST /webhooks/tts/downlink/ack - Handle downlink acknowledgments
|
||||
*
|
||||
* Note: These routes use webhook secret verification instead of JWT auth
|
||||
*/
|
||||
router.use('/webhooks/tts', ttsRoutes);
|
||||
|
||||
/**
|
||||
* Reading routes:
|
||||
* - GET /readings - List all readings with filtering
|
||||
* - GET /readings/summary - Get consumption summary
|
||||
* - GET /readings/:id - Get reading by ID
|
||||
* - POST /readings - Create reading
|
||||
* - DELETE /readings/:id - Delete reading (admin only)
|
||||
*/
|
||||
router.use('/readings', readingRoutes);
|
||||
|
||||
/**
|
||||
* Bulk upload routes:
|
||||
* - POST /bulk-upload/meters - Upload Excel file with meters data
|
||||
* - GET /bulk-upload/meters/template - Download Excel template for meters
|
||||
*/
|
||||
router.use('/bulk-upload', bulkUploadRoutes);
|
||||
|
||||
export default router;
|
||||
61
water-api/src/routes/meter.routes.ts
Normal file
61
water-api/src/routes/meter.routes.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||
import { validateCreateMeter, validateUpdateMeter } from '../validators/meter.validator';
|
||||
import * as meterController from '../controllers/meter.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /meters
|
||||
* Public endpoint - list all meters with pagination and filtering
|
||||
* Query params: page, pageSize, project_id, status, area_name, meter_type, search
|
||||
* Response: { success: true, data: Meter[], pagination: {...} }
|
||||
*/
|
||||
router.get('/', meterController.getAll);
|
||||
|
||||
/**
|
||||
* GET /meters/:id
|
||||
* Public endpoint - get a single meter by ID with device info
|
||||
* Response: { success: true, data: MeterWithDevice }
|
||||
*/
|
||||
router.get('/:id', meterController.getById);
|
||||
|
||||
/**
|
||||
* GET /meters/:id/readings
|
||||
* Public endpoint - get meter readings history
|
||||
* Query params: start_date, end_date
|
||||
* Response: { success: true, data: MeterReading[] }
|
||||
*/
|
||||
router.get('/:id/readings', meterController.getReadings);
|
||||
|
||||
/**
|
||||
* POST /meters
|
||||
* Protected endpoint - create a new meter
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Body: { serial_number: string, name?: string, project_id: string, device_id?: string,
|
||||
* area_name?: string, location?: string, meter_type?: string, status?: string,
|
||||
* installation_date?: string }
|
||||
* Response: { success: true, data: Meter }
|
||||
*/
|
||||
router.post('/', authenticateToken, validateCreateMeter, meterController.create);
|
||||
|
||||
/**
|
||||
* PUT /meters/:id
|
||||
* Protected endpoint - update an existing meter
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Body: { serial_number?: string, name?: string, project_id?: string, device_id?: string,
|
||||
* area_name?: string, location?: string, meter_type?: string, status?: string,
|
||||
* installation_date?: string }
|
||||
* Response: { success: true, data: Meter }
|
||||
*/
|
||||
router.put('/:id', authenticateToken, validateUpdateMeter, meterController.update);
|
||||
|
||||
/**
|
||||
* DELETE /meters/:id
|
||||
* Protected endpoint - delete a meter (requires admin role)
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Response: { success: true, data: { message: string } }
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, requireRole('admin', 'ADMIN'), meterController.deleteMeter);
|
||||
|
||||
export default router;
|
||||
56
water-api/src/routes/project.routes.ts
Normal file
56
water-api/src/routes/project.routes.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||
import { validateCreateProject, validateUpdateProject } from '../validators/project.validator';
|
||||
import * as projectController from '../controllers/project.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /projects
|
||||
* Public endpoint - list all projects with pagination
|
||||
* Query params: page, pageSize, status, area_name, search
|
||||
* Response: { success: true, data: Project[], pagination: {...} }
|
||||
*/
|
||||
router.get('/', projectController.getAll);
|
||||
|
||||
/**
|
||||
* GET /projects/:id
|
||||
* Public endpoint - get a single project by ID
|
||||
* Response: { success: true, data: Project }
|
||||
*/
|
||||
router.get('/:id', projectController.getById);
|
||||
|
||||
/**
|
||||
* GET /projects/:id/stats
|
||||
* Public endpoint - get project statistics
|
||||
* Response: { success: true, data: ProjectStats }
|
||||
*/
|
||||
router.get('/:id/stats', projectController.getStats);
|
||||
|
||||
/**
|
||||
* POST /projects
|
||||
* Protected endpoint - create a new project
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Body: { name: string, description?: string, area_name?: string, location?: string, status?: string }
|
||||
* Response: { success: true, data: Project }
|
||||
*/
|
||||
router.post('/', authenticateToken, validateCreateProject, projectController.create);
|
||||
|
||||
/**
|
||||
* PUT /projects/:id
|
||||
* Protected endpoint - update an existing project
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Body: { name?: string, description?: string, area_name?: string, location?: string, status?: string }
|
||||
* Response: { success: true, data: Project }
|
||||
*/
|
||||
router.put('/:id', authenticateToken, validateUpdateProject, projectController.update);
|
||||
|
||||
/**
|
||||
* DELETE /projects/:id
|
||||
* Protected endpoint - delete a project (requires admin role)
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Response: { success: true, data: { message: string } }
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, requireRole('admin', 'ADMIN'), projectController.deleteProject);
|
||||
|
||||
export default router;
|
||||
48
water-api/src/routes/reading.routes.ts
Normal file
48
water-api/src/routes/reading.routes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||
import * as readingController from '../controllers/reading.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /readings/summary
|
||||
* Public endpoint - get consumption summary statistics
|
||||
* Query params: project_id
|
||||
* Response: { success: true, data: { totalReadings, totalMeters, avgReading, lastReadingDate } }
|
||||
*/
|
||||
router.get('/summary', readingController.getSummary);
|
||||
|
||||
/**
|
||||
* GET /readings
|
||||
* Public endpoint - list all readings with pagination and filtering
|
||||
* Query params: page, pageSize, meter_id, project_id, area_name, start_date, end_date, reading_type
|
||||
* Response: { success: true, data: Reading[], pagination: {...} }
|
||||
*/
|
||||
router.get('/', readingController.getAll);
|
||||
|
||||
/**
|
||||
* GET /readings/:id
|
||||
* Public endpoint - get a single reading by ID
|
||||
* Response: { success: true, data: ReadingWithMeter }
|
||||
*/
|
||||
router.get('/:id', readingController.getById);
|
||||
|
||||
/**
|
||||
* POST /readings
|
||||
* Protected endpoint - create a new reading
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Body: { meter_id: string, device_id?: string, reading_value: number, reading_type?: string,
|
||||
* battery_level?: number, signal_strength?: number, raw_payload?: string, received_at?: string }
|
||||
* Response: { success: true, data: Reading }
|
||||
*/
|
||||
router.post('/', authenticateToken, readingController.create);
|
||||
|
||||
/**
|
||||
* DELETE /readings/:id
|
||||
* Protected endpoint - delete a reading (requires admin role)
|
||||
* Headers: Authorization: Bearer <accessToken>
|
||||
* Response: { success: true, data: { message: string } }
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, requireRole('admin', 'ADMIN'), readingController.deleteReading);
|
||||
|
||||
export default router;
|
||||
50
water-api/src/routes/role.routes.ts
Normal file
50
water-api/src/routes/role.routes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||
import { validateCreateRole, validateUpdateRole } from '../validators/role.validator';
|
||||
import * as roleController from '../controllers/role.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* All routes require authentication
|
||||
*/
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /roles
|
||||
* List all roles (all authenticated users)
|
||||
* Response: { success, message, data: Role[] }
|
||||
*/
|
||||
router.get('/', roleController.getAllRoles);
|
||||
|
||||
/**
|
||||
* GET /roles/:id
|
||||
* Get a single role by ID with user count (all authenticated users)
|
||||
* Response: { success, message, data: RoleWithUserCount }
|
||||
*/
|
||||
router.get('/:id', roleController.getRoleById);
|
||||
|
||||
/**
|
||||
* POST /roles
|
||||
* Create a new role (admin only)
|
||||
* Body: { name: 'ADMIN'|'OPERATOR'|'VIEWER', description?, permissions? }
|
||||
* Response: { success, message, data: Role }
|
||||
*/
|
||||
router.post('/', requireRole('ADMIN'), validateCreateRole, roleController.createRole);
|
||||
|
||||
/**
|
||||
* PUT /roles/:id
|
||||
* Update a role (admin only)
|
||||
* Body: { name?, description?, permissions? }
|
||||
* Response: { success, message, data: Role }
|
||||
*/
|
||||
router.put('/:id', requireRole('ADMIN'), validateUpdateRole, roleController.updateRole);
|
||||
|
||||
/**
|
||||
* DELETE /roles/:id
|
||||
* Delete a role (admin only, only if no users assigned)
|
||||
* Response: { success, message }
|
||||
*/
|
||||
router.delete('/:id', requireRole('ADMIN'), roleController.deleteRole);
|
||||
|
||||
export default router;
|
||||
148
water-api/src/routes/tts.routes.ts
Normal file
148
water-api/src/routes/tts.routes.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Router } from 'express';
|
||||
import * as ttsController from '../controllers/tts.controller';
|
||||
import {
|
||||
verifyTtsWebhook,
|
||||
logTtsWebhook,
|
||||
extractTtsPayloadInfo,
|
||||
} from '../middleware/ttsWebhook.middleware';
|
||||
import {
|
||||
validateUplink,
|
||||
validateJoin,
|
||||
validateDownlinkAck,
|
||||
} from '../validators/tts.validator';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* TTS Webhook Routes
|
||||
*
|
||||
* These routes handle incoming webhooks from The Things Stack (TTS).
|
||||
* They do NOT require standard authentication (Bearer token).
|
||||
* Instead, they use webhook secret verification via the X-Downlink-Apikey
|
||||
* or X-Webhook-Signature headers.
|
||||
*
|
||||
* Mount these routes at: /api/webhooks/tts
|
||||
*
|
||||
* Environment variables for configuration:
|
||||
* - TTS_WEBHOOK_SECRET: Secret for HMAC signature verification
|
||||
* - TTS_API_KEY: API key for X-Downlink-Apikey verification
|
||||
* - TTS_REQUIRE_WEBHOOK_VERIFICATION: Set to 'false' to disable verification (not recommended)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Middleware chain for all TTS webhooks:
|
||||
* 1. Log incoming request
|
||||
* 2. Verify webhook authenticity
|
||||
* 3. Extract device info from payload
|
||||
*/
|
||||
const commonMiddleware = [
|
||||
logTtsWebhook,
|
||||
verifyTtsWebhook,
|
||||
extractTtsPayloadInfo,
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/webhooks/tts/health
|
||||
* Health check endpoint
|
||||
*
|
||||
* Can be used to verify the webhook endpoint is reachable.
|
||||
* No authentication required.
|
||||
*/
|
||||
router.get('/health', ttsController.healthCheck);
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/tts/uplink
|
||||
* Handle uplink messages from devices
|
||||
*
|
||||
* Payload structure:
|
||||
* {
|
||||
* "end_device_ids": { "device_id": "...", "dev_eui": "...", ... },
|
||||
* "received_at": "2024-01-01T00:00:00Z",
|
||||
* "uplink_message": {
|
||||
* "f_port": 1,
|
||||
* "frm_payload": "base64...",
|
||||
* "decoded_payload": { ... },
|
||||
* "rx_metadata": [{ "gateway_ids": {...}, "rssi": -100, "snr": 5.5 }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Response: 200 OK on success (even if processing fails, to prevent TTS retries)
|
||||
* Response: 500 Internal Server Error for unexpected errors (TTS will retry)
|
||||
*/
|
||||
router.post(
|
||||
'/uplink',
|
||||
...commonMiddleware,
|
||||
validateUplink,
|
||||
ttsController.handleUplink
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/tts/join
|
||||
* Handle join accept events
|
||||
*
|
||||
* Received when a device successfully joins the LoRaWAN network.
|
||||
*
|
||||
* Payload structure:
|
||||
* {
|
||||
* "end_device_ids": { "device_id": "...", "dev_eui": "...", ... },
|
||||
* "received_at": "2024-01-01T00:00:00Z",
|
||||
* "join_accept": { "session_key_id": "...", "received_at": "..." }
|
||||
* }
|
||||
*/
|
||||
router.post(
|
||||
'/join',
|
||||
...commonMiddleware,
|
||||
validateJoin,
|
||||
ttsController.handleJoin
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/tts/downlink/ack
|
||||
* Handle downlink acknowledgments and status updates
|
||||
*
|
||||
* Received when:
|
||||
* - A confirmed downlink is acknowledged by the device
|
||||
* - A downlink is sent to a gateway
|
||||
* - A downlink fails to be delivered
|
||||
* - A downlink is queued
|
||||
*
|
||||
* The payload will contain one of:
|
||||
* - downlink_ack: Device acknowledged the downlink
|
||||
* - downlink_sent: Downlink was sent to gateway
|
||||
* - downlink_failed: Downlink failed to be delivered
|
||||
* - downlink_queued: Downlink was queued for later delivery
|
||||
*/
|
||||
router.post(
|
||||
'/downlink/ack',
|
||||
...commonMiddleware,
|
||||
validateDownlinkAck,
|
||||
ttsController.handleDownlinkAck
|
||||
);
|
||||
|
||||
/**
|
||||
* Alias routes for compatibility with different TTS webhook configurations
|
||||
*/
|
||||
|
||||
// Alternative path for downlink events
|
||||
router.post(
|
||||
'/downlink/sent',
|
||||
...commonMiddleware,
|
||||
validateDownlinkAck,
|
||||
ttsController.handleDownlinkAck
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/downlink/failed',
|
||||
...commonMiddleware,
|
||||
validateDownlinkAck,
|
||||
ttsController.handleDownlinkAck
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/downlink/queued',
|
||||
...commonMiddleware,
|
||||
validateDownlinkAck,
|
||||
ttsController.handleDownlinkAck
|
||||
);
|
||||
|
||||
export default router;
|
||||
63
water-api/src/routes/user.routes.ts
Normal file
63
water-api/src/routes/user.routes.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||
import {
|
||||
validateCreateUser,
|
||||
validateUpdateUser,
|
||||
validateChangePassword,
|
||||
} from '../validators/user.validator';
|
||||
import * as userController from '../controllers/user.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* All routes require authentication
|
||||
*/
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /users
|
||||
* List all users (admin only)
|
||||
* Query params: role_id, is_active, search, page, limit, sortBy, sortOrder
|
||||
* Response: { success, message, data: User[], pagination }
|
||||
*/
|
||||
router.get('/', requireRole('ADMIN'), userController.getAllUsers);
|
||||
|
||||
/**
|
||||
* GET /users/:id
|
||||
* Get a single user by ID (admin or self)
|
||||
* Response: { success, message, data: User }
|
||||
*/
|
||||
router.get('/:id', userController.getUserById);
|
||||
|
||||
/**
|
||||
* POST /users
|
||||
* Create a new user (admin only)
|
||||
* Body: { email, password, first_name, last_name, role_id, is_active? }
|
||||
* Response: { success, message, data: User }
|
||||
*/
|
||||
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser);
|
||||
|
||||
/**
|
||||
* PUT /users/:id
|
||||
* Update a user (admin can update all, self can update limited fields)
|
||||
* Body: { email?, first_name?, last_name?, role_id?, is_active? }
|
||||
* Response: { success, message, data: User }
|
||||
*/
|
||||
router.put('/:id', validateUpdateUser, userController.updateUser);
|
||||
|
||||
/**
|
||||
* DELETE /users/:id
|
||||
* Deactivate a user (soft delete, admin only)
|
||||
* Response: { success, message }
|
||||
*/
|
||||
router.delete('/:id', requireRole('ADMIN'), userController.deleteUser);
|
||||
|
||||
/**
|
||||
* PUT /users/:id/password
|
||||
* Change user password (self only)
|
||||
* Body: { current_password, new_password }
|
||||
* Response: { success, message }
|
||||
*/
|
||||
router.put('/:id/password', validateChangePassword, userController.changePassword);
|
||||
|
||||
export default router;
|
||||
0
water-api/src/services/.gitkeep
Normal file
0
water-api/src/services/.gitkeep
Normal file
252
water-api/src/services/auth.service.ts
Normal file
252
water-api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { query } from '../config/database';
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyRefreshToken,
|
||||
} from '../utils/jwt';
|
||||
import { comparePassword } from '../utils/password';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Hash a token for storage
|
||||
*/
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication service response types
|
||||
*/
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatarUrl?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LoginResult extends AuthTokens {
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user with email and password
|
||||
* Generates access and refresh tokens on successful login
|
||||
* Stores hashed refresh token in database
|
||||
* @param email - User email
|
||||
* @param password - User password
|
||||
* @returns Access and refresh tokens with user info
|
||||
*/
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
// Find user by email with role name
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password_hash: string;
|
||||
avatar_url: string | null;
|
||||
role_name: string;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await comparePassword(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role_name,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
// Hash and store refresh token
|
||||
const hashedRefreshToken = hashToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await query(
|
||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[user.id, hashedRefreshToken, expiresAt]
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await query(
|
||||
`UPDATE users SET last_login = NOW() WHERE id = $1`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role_name,
|
||||
avatarUrl: user.avatar_url,
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using a valid refresh token
|
||||
* Verifies the refresh token exists in database and is not expired
|
||||
* @param refreshToken - The refresh token
|
||||
* @returns New access token
|
||||
*/
|
||||
export async function refresh(refreshToken: string): Promise<{ accessToken: string }> {
|
||||
// Verify JWT signature
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
|
||||
if (!decoded) {
|
||||
throw new Error('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Hash token to check against database
|
||||
const hashedToken = hashToken(refreshToken);
|
||||
|
||||
// Find token in database
|
||||
const tokenResult = await query<{
|
||||
id: string;
|
||||
expires_at: Date;
|
||||
}>(
|
||||
`SELECT id, expires_at FROM refresh_tokens
|
||||
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||
LIMIT 1`,
|
||||
[hashedToken, decoded.id]
|
||||
);
|
||||
|
||||
const storedToken = tokenResult.rows[0];
|
||||
|
||||
if (!storedToken) {
|
||||
throw new Error('Refresh token not found or revoked');
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date() > storedToken.expires_at) {
|
||||
// Clean up expired token
|
||||
await query(
|
||||
`DELETE FROM refresh_tokens WHERE id = $1`,
|
||||
[storedToken.id]
|
||||
);
|
||||
|
||||
throw new Error('Refresh token expired');
|
||||
}
|
||||
|
||||
// Get user data for new access token
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
role_name: string;
|
||||
}>(
|
||||
`SELECT u.id, u.email, r.name as role_name
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = $1 AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = generateAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role_name,
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by revoking the specified refresh token
|
||||
* @param userId - The user ID
|
||||
* @param refreshToken - The refresh token to revoke
|
||||
*/
|
||||
export async function logout(
|
||||
userId: string,
|
||||
refreshToken: string
|
||||
): Promise<void> {
|
||||
const hashedToken = hashToken(refreshToken);
|
||||
|
||||
// Revoke the specific refresh token
|
||||
await query(
|
||||
`UPDATE refresh_tokens SET revoked_at = NOW()
|
||||
WHERE token_hash = $1 AND user_id = $2`,
|
||||
[hashedToken, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user's profile
|
||||
* @param userId - The user ID
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getMe(userId: string): Promise<UserProfile> {
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
role_name: string;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = $1 AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role_name,
|
||||
avatarUrl: user.avatar_url,
|
||||
createdAt: user.created_at,
|
||||
};
|
||||
}
|
||||
317
water-api/src/services/bulk-upload.service.ts
Normal file
317
water-api/src/services/bulk-upload.service.ts
Normal 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' }));
|
||||
}
|
||||
296
water-api/src/services/concentrator.service.ts
Normal file
296
water-api/src/services/concentrator.service.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { pool } from '../config/database';
|
||||
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
|
||||
|
||||
/**
|
||||
* Concentrator types
|
||||
*/
|
||||
export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES';
|
||||
|
||||
/**
|
||||
* Concentrator entity interface
|
||||
*/
|
||||
export interface Concentrator {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
name: string | null;
|
||||
project_id: string;
|
||||
location: string | null;
|
||||
type: ConcentratorType;
|
||||
status: 'online' | 'offline' | 'maintenance' | 'unknown';
|
||||
ip_address: string | null;
|
||||
firmware_version: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concentrator with gateway count
|
||||
*/
|
||||
export interface ConcentratorWithCount extends Concentrator {
|
||||
gateway_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for concentrators
|
||||
*/
|
||||
export interface ConcentratorFilters {
|
||||
project_id?: string;
|
||||
status?: string;
|
||||
type?: ConcentratorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination options
|
||||
*/
|
||||
export interface PaginationOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated result
|
||||
*/
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all concentrators with optional filters and pagination
|
||||
* @param filters - Optional filter criteria
|
||||
* @param pagination - Optional pagination options
|
||||
* @returns Paginated list of concentrators
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: ConcentratorFilters,
|
||||
pagination?: PaginationOptions
|
||||
): Promise<PaginatedResult<Concentrator>> {
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const sortBy = pagination?.sortBy || 'created_at';
|
||||
const sortOrder = pagination?.sortOrder || 'desc';
|
||||
|
||||
// Build WHERE clause
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters?.project_id) {
|
||||
conditions.push(`project_id = $${paramIndex}`);
|
||||
params.push(filters.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(`status = $${paramIndex}`);
|
||||
params.push(filters.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.type) {
|
||||
conditions.push(`type = $${paramIndex}`);
|
||||
params.push(filters.type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
const allowedSortColumns = ['id', 'serial_number', 'name', 'status', 'created_at', 'updated_at'];
|
||||
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `SELECT COUNT(*) FROM concentrators ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get data
|
||||
const dataQuery = `
|
||||
SELECT * FROM concentrators
|
||||
${whereClause}
|
||||
ORDER BY ${safeSortBy} ${safeSortOrder}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const dataResult = await pool.query<Concentrator>(dataQuery, params);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single concentrator by ID with gateway count
|
||||
* @param id - Concentrator UUID
|
||||
* @returns Concentrator with gateway count or null
|
||||
*/
|
||||
export async function getById(id: string): Promise<ConcentratorWithCount | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
c.*,
|
||||
COALESCE(COUNT(g.id), 0)::int as gateway_count
|
||||
FROM concentrators c
|
||||
LEFT JOIN gateways g ON g.concentrator_id = c.id
|
||||
WHERE c.id = $1
|
||||
GROUP BY c.id
|
||||
`;
|
||||
|
||||
const result = await pool.query<ConcentratorWithCount>(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new concentrator
|
||||
* @param data - Concentrator creation data
|
||||
* @returns Created concentrator
|
||||
*/
|
||||
export async function create(data: CreateConcentratorInput): Promise<Concentrator> {
|
||||
const query = `
|
||||
INSERT INTO concentrators (serial_number, name, project_id, location, type, status, ip_address, firmware_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const params = [
|
||||
data.serial_number,
|
||||
data.name || null,
|
||||
data.project_id,
|
||||
data.location || null,
|
||||
data.type || 'LORA',
|
||||
data.status || 'ACTIVE',
|
||||
data.ip_address || null,
|
||||
data.firmware_version || null,
|
||||
];
|
||||
|
||||
const result = await pool.query<Concentrator>(query, params);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing concentrator
|
||||
* @param id - Concentrator UUID
|
||||
* @param data - Update data
|
||||
* @returns Updated concentrator or null if not found
|
||||
*/
|
||||
export async function update(id: string, data: UpdateConcentratorInput): Promise<Concentrator | null> {
|
||||
// Build SET clause dynamically
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.serial_number !== undefined) {
|
||||
updates.push(`serial_number = $${paramIndex}`);
|
||||
params.push(data.serial_number);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
params.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.project_id !== undefined) {
|
||||
updates.push(`project_id = $${paramIndex}`);
|
||||
params.push(data.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
updates.push(`location = $${paramIndex}`);
|
||||
params.push(data.location);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.type !== undefined) {
|
||||
updates.push(`type = $${paramIndex}`);
|
||||
params.push(data.type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex}`);
|
||||
params.push(data.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.ip_address !== undefined) {
|
||||
updates.push(`ip_address = $${paramIndex}`);
|
||||
params.push(data.ip_address);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.firmware_version !== undefined) {
|
||||
updates.push(`firmware_version = $${paramIndex}`);
|
||||
params.push(data.firmware_version);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// No updates provided, return existing record
|
||||
const existing = await getById(id);
|
||||
return existing;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
const query = `
|
||||
UPDATE concentrators
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *
|
||||
`;
|
||||
params.push(id);
|
||||
|
||||
const result = await pool.query<Concentrator>(query, params);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a concentrator
|
||||
* Checks for dependent gateways before deletion
|
||||
* @param id - Concentrator UUID
|
||||
* @returns True if deleted, throws error if has dependencies
|
||||
*/
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
// Check for dependent gateways
|
||||
const gatewayCheck = await pool.query(
|
||||
'SELECT COUNT(*) FROM gateways WHERE concentrator_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
const gatewayCount = parseInt(gatewayCheck.rows[0].count, 10);
|
||||
|
||||
if (gatewayCount > 0) {
|
||||
throw new Error(`Cannot delete concentrator: ${gatewayCount} gateway(s) are associated with it`);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM concentrators WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
341
water-api/src/services/device.service.ts
Normal file
341
water-api/src/services/device.service.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { pool } from '../config/database';
|
||||
import { CreateDeviceInput, UpdateDeviceInput } from '../validators/device.validator';
|
||||
|
||||
/**
|
||||
* Device entity interface
|
||||
*/
|
||||
export interface Device {
|
||||
id: string;
|
||||
dev_eui: string;
|
||||
name: string | null;
|
||||
device_type: string | null;
|
||||
project_id: string;
|
||||
gateway_id: string | null;
|
||||
status: 'online' | 'offline' | 'maintenance' | 'unknown';
|
||||
tts_device_id: string | null;
|
||||
tts_status: string | null;
|
||||
tts_last_seen: Date | null;
|
||||
app_key: string | null;
|
||||
join_eui: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device with meter info
|
||||
*/
|
||||
export interface DeviceWithMeter extends Device {
|
||||
meter_id: string | null;
|
||||
meter_number: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for devices
|
||||
*/
|
||||
export interface DeviceFilters {
|
||||
project_id?: string;
|
||||
gateway_id?: string;
|
||||
status?: string;
|
||||
device_type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination options
|
||||
*/
|
||||
export interface PaginationOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated result
|
||||
*/
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices with optional filters and pagination
|
||||
* @param filters - Optional filter criteria
|
||||
* @param pagination - Optional pagination options
|
||||
* @returns Paginated list of devices
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: DeviceFilters,
|
||||
pagination?: PaginationOptions
|
||||
): Promise<PaginatedResult<Device>> {
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const sortBy = pagination?.sortBy || 'created_at';
|
||||
const sortOrder = pagination?.sortOrder || 'desc';
|
||||
|
||||
// Build WHERE clause
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters?.project_id) {
|
||||
conditions.push(`project_id = $${paramIndex}`);
|
||||
params.push(filters.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.gateway_id) {
|
||||
conditions.push(`gateway_id = $${paramIndex}`);
|
||||
params.push(filters.gateway_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(`status = $${paramIndex}`);
|
||||
params.push(filters.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.device_type) {
|
||||
conditions.push(`device_type = $${paramIndex}`);
|
||||
params.push(filters.device_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
const allowedSortColumns = ['id', 'dev_eui', 'name', 'device_type', 'status', 'created_at', 'updated_at'];
|
||||
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `SELECT COUNT(*) FROM devices ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get data
|
||||
const dataQuery = `
|
||||
SELECT * FROM devices
|
||||
${whereClause}
|
||||
ORDER BY ${safeSortBy} ${safeSortOrder}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const dataResult = await pool.query<Device>(dataQuery, params);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single device by ID with meter info
|
||||
* @param id - Device UUID
|
||||
* @returns Device with meter info or null
|
||||
*/
|
||||
export async function getById(id: string): Promise<DeviceWithMeter | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
d.*,
|
||||
m.id as meter_id,
|
||||
m.meter_number
|
||||
FROM devices d
|
||||
LEFT JOIN meters m ON m.device_id = d.id
|
||||
WHERE d.id = $1
|
||||
`;
|
||||
|
||||
const result = await pool.query<DeviceWithMeter>(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a device by DevEUI
|
||||
* @param devEui - Device DevEUI
|
||||
* @returns Device or null
|
||||
*/
|
||||
export async function getByDevEui(devEui: string): Promise<Device | null> {
|
||||
const query = `
|
||||
SELECT * FROM devices
|
||||
WHERE dev_eui = $1
|
||||
`;
|
||||
|
||||
const result = await pool.query<Device>(query, [devEui.toUpperCase()]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new device
|
||||
* @param data - Device creation data
|
||||
* @returns Created device
|
||||
*/
|
||||
export async function create(data: CreateDeviceInput): Promise<Device> {
|
||||
const query = `
|
||||
INSERT INTO devices (dev_eui, name, device_type, project_id, gateway_id, status, tts_device_id, app_key, join_eui)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const params = [
|
||||
data.dev_eui.toUpperCase(),
|
||||
data.name || null,
|
||||
data.device_type || null,
|
||||
data.project_id,
|
||||
data.gateway_id || null,
|
||||
data.status || 'unknown',
|
||||
data.tts_device_id || null,
|
||||
data.app_key || null,
|
||||
data.join_eui || null,
|
||||
];
|
||||
|
||||
const result = await pool.query<Device>(query, params);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing device
|
||||
* @param id - Device UUID
|
||||
* @param data - Update data
|
||||
* @returns Updated device or null if not found
|
||||
*/
|
||||
export async function update(id: string, data: UpdateDeviceInput): Promise<Device | null> {
|
||||
// Build SET clause dynamically
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.dev_eui !== undefined) {
|
||||
updates.push(`dev_eui = $${paramIndex}`);
|
||||
params.push(data.dev_eui.toUpperCase());
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
params.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.device_type !== undefined) {
|
||||
updates.push(`device_type = $${paramIndex}`);
|
||||
params.push(data.device_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.project_id !== undefined) {
|
||||
updates.push(`project_id = $${paramIndex}`);
|
||||
params.push(data.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.gateway_id !== undefined) {
|
||||
updates.push(`gateway_id = $${paramIndex}`);
|
||||
params.push(data.gateway_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex}`);
|
||||
params.push(data.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.tts_device_id !== undefined) {
|
||||
updates.push(`tts_device_id = $${paramIndex}`);
|
||||
params.push(data.tts_device_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.app_key !== undefined) {
|
||||
updates.push(`app_key = $${paramIndex}`);
|
||||
params.push(data.app_key);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.join_eui !== undefined) {
|
||||
updates.push(`join_eui = $${paramIndex}`);
|
||||
params.push(data.join_eui);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// No updates provided, return existing record
|
||||
const existing = await getById(id);
|
||||
return existing;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
const query = `
|
||||
UPDATE devices
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *
|
||||
`;
|
||||
params.push(id);
|
||||
|
||||
const result = await pool.query<Device>(query, params);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device
|
||||
* Sets meter's device_id to null if a meter is associated
|
||||
* @param id - Device UUID
|
||||
* @returns True if deleted
|
||||
*/
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
// Set meter's device_id to null if associated
|
||||
await pool.query(
|
||||
'UPDATE meters SET device_id = NULL WHERE device_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM devices WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TTS status fields
|
||||
* @param id - Device UUID
|
||||
* @param status - TTS status
|
||||
* @param lastSeen - Last seen timestamp
|
||||
* @returns Updated device or null
|
||||
*/
|
||||
export async function updateTtsStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
lastSeen: Date
|
||||
): Promise<Device | null> {
|
||||
const query = `
|
||||
UPDATE devices
|
||||
SET tts_status = $1, tts_last_seen = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query<Device>(query, [status, lastSeen, id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
324
water-api/src/services/gateway.service.ts
Normal file
324
water-api/src/services/gateway.service.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { pool } from '../config/database';
|
||||
import { CreateGatewayInput, UpdateGatewayInput } from '../validators/gateway.validator';
|
||||
|
||||
/**
|
||||
* Gateway entity interface
|
||||
*/
|
||||
export interface Gateway {
|
||||
id: string;
|
||||
gateway_id: string;
|
||||
name: string | null;
|
||||
project_id: string;
|
||||
concentrator_id: string | null;
|
||||
location: string | null;
|
||||
status: 'online' | 'offline' | 'maintenance' | 'unknown';
|
||||
tts_gateway_id: string | null;
|
||||
tts_status: string | null;
|
||||
tts_last_seen: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway with device count
|
||||
*/
|
||||
export interface GatewayWithCount extends Gateway {
|
||||
device_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for gateways
|
||||
*/
|
||||
export interface GatewayFilters {
|
||||
project_id?: string;
|
||||
concentrator_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination options
|
||||
*/
|
||||
export interface PaginationOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated result
|
||||
*/
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all gateways with optional filters and pagination
|
||||
* @param filters - Optional filter criteria
|
||||
* @param pagination - Optional pagination options
|
||||
* @returns Paginated list of gateways
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: GatewayFilters,
|
||||
pagination?: PaginationOptions
|
||||
): Promise<PaginatedResult<Gateway>> {
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const sortBy = pagination?.sortBy || 'created_at';
|
||||
const sortOrder = pagination?.sortOrder || 'desc';
|
||||
|
||||
// Build WHERE clause
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters?.project_id) {
|
||||
conditions.push(`project_id = $${paramIndex}`);
|
||||
params.push(filters.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.concentrator_id) {
|
||||
conditions.push(`concentrator_id = $${paramIndex}`);
|
||||
params.push(filters.concentrator_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(`status = $${paramIndex}`);
|
||||
params.push(filters.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
const allowedSortColumns = ['id', 'gateway_id', 'name', 'status', 'created_at', 'updated_at'];
|
||||
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `SELECT COUNT(*) FROM gateways ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get data
|
||||
const dataQuery = `
|
||||
SELECT * FROM gateways
|
||||
${whereClause}
|
||||
ORDER BY ${safeSortBy} ${safeSortOrder}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const dataResult = await pool.query<Gateway>(dataQuery, params);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single gateway by ID with device count
|
||||
* @param id - Gateway UUID
|
||||
* @returns Gateway with device count or null
|
||||
*/
|
||||
export async function getById(id: string): Promise<GatewayWithCount | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
g.*,
|
||||
COALESCE(COUNT(d.id), 0)::int as device_count
|
||||
FROM gateways g
|
||||
LEFT JOIN devices d ON d.gateway_id = g.id
|
||||
WHERE g.id = $1
|
||||
GROUP BY g.id
|
||||
`;
|
||||
|
||||
const result = await pool.query<GatewayWithCount>(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get devices for a specific gateway
|
||||
* @param gatewayId - Gateway UUID
|
||||
* @returns List of devices
|
||||
*/
|
||||
export async function getDevices(gatewayId: string): Promise<unknown[]> {
|
||||
const query = `
|
||||
SELECT * FROM devices
|
||||
WHERE gateway_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [gatewayId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new gateway
|
||||
* @param data - Gateway creation data
|
||||
* @returns Created gateway
|
||||
*/
|
||||
export async function create(data: CreateGatewayInput): Promise<Gateway> {
|
||||
const query = `
|
||||
INSERT INTO gateways (gateway_id, name, project_id, concentrator_id, location, status, tts_gateway_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const params = [
|
||||
data.gateway_id,
|
||||
data.name || null,
|
||||
data.project_id,
|
||||
data.concentrator_id || null,
|
||||
data.location || null,
|
||||
data.status || 'unknown',
|
||||
data.tts_gateway_id || null,
|
||||
];
|
||||
|
||||
const result = await pool.query<Gateway>(query, params);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing gateway
|
||||
* @param id - Gateway UUID
|
||||
* @param data - Update data
|
||||
* @returns Updated gateway or null if not found
|
||||
*/
|
||||
export async function update(id: string, data: UpdateGatewayInput): Promise<Gateway | null> {
|
||||
// Build SET clause dynamically
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.gateway_id !== undefined) {
|
||||
updates.push(`gateway_id = $${paramIndex}`);
|
||||
params.push(data.gateway_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
params.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.project_id !== undefined) {
|
||||
updates.push(`project_id = $${paramIndex}`);
|
||||
params.push(data.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.concentrator_id !== undefined) {
|
||||
updates.push(`concentrator_id = $${paramIndex}`);
|
||||
params.push(data.concentrator_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
updates.push(`location = $${paramIndex}`);
|
||||
params.push(data.location);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex}`);
|
||||
params.push(data.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.tts_gateway_id !== undefined) {
|
||||
updates.push(`tts_gateway_id = $${paramIndex}`);
|
||||
params.push(data.tts_gateway_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// No updates provided, return existing record
|
||||
const existing = await getById(id);
|
||||
return existing;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
const query = `
|
||||
UPDATE gateways
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *
|
||||
`;
|
||||
params.push(id);
|
||||
|
||||
const result = await pool.query<Gateway>(query, params);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a gateway
|
||||
* Checks for dependent devices before deletion
|
||||
* @param id - Gateway UUID
|
||||
* @returns True if deleted, throws error if has dependencies
|
||||
*/
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
// Check for dependent devices
|
||||
const deviceCheck = await pool.query(
|
||||
'SELECT COUNT(*) FROM devices WHERE gateway_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
const deviceCount = parseInt(deviceCheck.rows[0].count, 10);
|
||||
|
||||
if (deviceCount > 0) {
|
||||
throw new Error(`Cannot delete gateway: ${deviceCount} device(s) are associated with it`);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM gateways WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TTS status fields
|
||||
* @param id - Gateway UUID
|
||||
* @param status - TTS status
|
||||
* @param lastSeen - Last seen timestamp
|
||||
* @returns Updated gateway or null
|
||||
*/
|
||||
export async function updateTtsStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
lastSeen: Date
|
||||
): Promise<Gateway | null> {
|
||||
const query = `
|
||||
UPDATE gateways
|
||||
SET tts_status = $1, tts_last_seen = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query<Gateway>(query, [status, lastSeen, id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
317
water-api/src/services/meter.service.ts
Normal file
317
water-api/src/services/meter.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { query } from '../config/database';
|
||||
|
||||
/**
|
||||
* Meter interface matching database schema
|
||||
* Meters are linked to concentrators (not directly to projects)
|
||||
*/
|
||||
export interface Meter {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
meter_id: string | null;
|
||||
name: string;
|
||||
concentrator_id: string;
|
||||
location: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
last_reading_value: number | null;
|
||||
last_reading_at: Date | null;
|
||||
installation_date: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meter with concentrator and project info
|
||||
*/
|
||||
export interface MeterWithDetails extends Meter {
|
||||
concentrator_name?: string;
|
||||
concentrator_serial?: string;
|
||||
project_id?: string;
|
||||
project_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination parameters
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter parameters for meters
|
||||
*/
|
||||
export interface MeterFilters {
|
||||
concentrator_id?: string;
|
||||
project_id?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated result interface
|
||||
*/
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a meter
|
||||
*/
|
||||
export interface CreateMeterInput {
|
||||
serial_number: string;
|
||||
meter_id?: string | null;
|
||||
name: string;
|
||||
concentrator_id: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
installation_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a meter
|
||||
*/
|
||||
export interface UpdateMeterInput {
|
||||
serial_number?: string;
|
||||
meter_id?: string | null;
|
||||
name?: string;
|
||||
concentrator_id?: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
installation_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all meters with optional filtering and pagination
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: MeterFilters,
|
||||
pagination?: PaginationParams
|
||||
): Promise<PaginatedResult<MeterWithDetails>> {
|
||||
const page = pagination?.page || 1;
|
||||
const pageSize = pagination?.pageSize || 50;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters?.concentrator_id) {
|
||||
conditions.push(`m.concentrator_id = $${paramIndex}`);
|
||||
params.push(filters.concentrator_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.project_id) {
|
||||
conditions.push(`c.project_id = $${paramIndex}`);
|
||||
params.push(filters.project_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(`m.status = $${paramIndex}`);
|
||||
params.push(filters.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.type) {
|
||||
conditions.push(`m.type = $${paramIndex}`);
|
||||
params.push(filters.type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
conditions.push(`(m.serial_number ILIKE $${paramIndex} OR m.name ILIKE $${paramIndex})`);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Count query
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM meters m
|
||||
JOIN concentrators c ON m.concentrator_id = c.id
|
||||
${whereClause}
|
||||
`;
|
||||
const countResult = await query<{ total: string }>(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
|
||||
// Data query with joins
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
m.id, m.serial_number, m.meter_id, m.name, m.concentrator_id, m.location, m.type,
|
||||
m.status, m.last_reading_value, m.last_reading_at, m.installation_date,
|
||||
m.created_at, m.updated_at,
|
||||
c.name as concentrator_name, c.serial_number as concentrator_serial,
|
||||
c.project_id, p.name as project_name
|
||||
FROM meters m
|
||||
JOIN concentrators c ON m.concentrator_id = c.id
|
||||
JOIN projects p ON c.project_id = p.id
|
||||
${whereClause}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await query<MeterWithDetails>(dataQuery, params);
|
||||
|
||||
return {
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single meter by ID with details
|
||||
*/
|
||||
export async function getById(id: string): Promise<MeterWithDetails | null> {
|
||||
const result = await query<MeterWithDetails>(
|
||||
`SELECT
|
||||
m.id, m.serial_number, m.meter_id, m.name, m.concentrator_id, m.location, m.type,
|
||||
m.status, m.last_reading_value, m.last_reading_at, m.installation_date,
|
||||
m.created_at, m.updated_at,
|
||||
c.name as concentrator_name, c.serial_number as concentrator_serial,
|
||||
c.project_id, p.name as project_name
|
||||
FROM meters m
|
||||
JOIN concentrators c ON m.concentrator_id = c.id
|
||||
JOIN projects p ON c.project_id = p.id
|
||||
WHERE m.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meter
|
||||
*/
|
||||
export async function create(data: CreateMeterInput): Promise<Meter> {
|
||||
const result = await query<Meter>(
|
||||
`INSERT INTO meters (serial_number, meter_id, name, concentrator_id, location, type, status, installation_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.serial_number,
|
||||
data.meter_id || null,
|
||||
data.name,
|
||||
data.concentrator_id,
|
||||
data.location || null,
|
||||
data.type || 'LORA',
|
||||
data.status || 'ACTIVE',
|
||||
data.installation_date || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing meter
|
||||
*/
|
||||
export async function update(id: string, data: UpdateMeterInput): Promise<Meter | null> {
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.serial_number !== undefined) {
|
||||
updates.push(`serial_number = $${paramIndex}`);
|
||||
params.push(data.serial_number);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.meter_id !== undefined) {
|
||||
updates.push(`meter_id = $${paramIndex}`);
|
||||
params.push(data.meter_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
params.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.concentrator_id !== undefined) {
|
||||
updates.push(`concentrator_id = $${paramIndex}`);
|
||||
params.push(data.concentrator_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
updates.push(`location = $${paramIndex}`);
|
||||
params.push(data.location);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.type !== undefined) {
|
||||
updates.push(`type = $${paramIndex}`);
|
||||
params.push(data.type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex}`);
|
||||
params.push(data.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.installation_date !== undefined) {
|
||||
updates.push(`installation_date = $${paramIndex}`);
|
||||
params.push(data.installation_date);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
if (updates.length === 1) {
|
||||
return getById(id) as Promise<Meter | null>;
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
const result = await query<Meter>(
|
||||
`UPDATE meters SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a meter
|
||||
*/
|
||||
export async function deleteMeter(id: string): Promise<boolean> {
|
||||
const result = await query('DELETE FROM meters WHERE id = $1', [id]);
|
||||
return (result.rowCount || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last reading value
|
||||
*/
|
||||
export async function updateLastReading(id: string, value: number): Promise<Meter | null> {
|
||||
const result = await query<Meter>(
|
||||
`UPDATE meters
|
||||
SET last_reading_value = $1, last_reading_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[value, id]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
308
water-api/src/services/project.service.ts
Normal file
308
water-api/src/services/project.service.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { query } from '../config/database';
|
||||
import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../validators/project.validator';
|
||||
|
||||
/**
|
||||
* Project interface matching database schema
|
||||
*/
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
area_name: string | null;
|
||||
location: string | null;
|
||||
status: ProjectStatusType;
|
||||
created_by: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project statistics interface
|
||||
*/
|
||||
export interface ProjectStats {
|
||||
meter_count: number;
|
||||
device_count: number;
|
||||
concentrator_count: number;
|
||||
active_meters: number;
|
||||
inactive_meters: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination parameters interface
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter parameters for projects
|
||||
*/
|
||||
export interface ProjectFilters {
|
||||
status?: ProjectStatusType;
|
||||
area_name?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated result interface
|
||||
*/
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects with optional filtering and pagination
|
||||
* @param filters - Optional filters for status and area_name
|
||||
* @param pagination - Optional pagination parameters
|
||||
* @returns Paginated list of projects
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: ProjectFilters,
|
||||
pagination?: PaginationParams
|
||||
): Promise<PaginatedResult<Project>> {
|
||||
const page = pagination?.page || 1;
|
||||
const pageSize = pagination?.pageSize || 10;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Build WHERE clause dynamically
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(`status = $${paramIndex}`);
|
||||
params.push(filters.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.area_name) {
|
||||
conditions.push(`area_name ILIKE $${paramIndex}`);
|
||||
params.push(`%${filters.area_name}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
conditions.push(`(name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `SELECT COUNT(*) as total FROM projects ${whereClause}`;
|
||||
const countResult = await query<{ total: string }>(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
|
||||
// Get paginated data
|
||||
const dataQuery = `
|
||||
SELECT id, name, description, area_name, location, status, created_by, created_at, updated_at
|
||||
FROM projects
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await query<Project>(dataQuery, params);
|
||||
|
||||
return {
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project by ID
|
||||
* @param id - Project UUID
|
||||
* @returns Project or null if not found
|
||||
*/
|
||||
export async function getById(id: string): Promise<Project | null> {
|
||||
const result = await query<Project>(
|
||||
`SELECT id, name, description, area_name, location, status, created_by, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
* @param data - Project data
|
||||
* @param userId - ID of the user creating the project
|
||||
* @returns Created project
|
||||
*/
|
||||
export async function create(data: CreateProjectInput, userId: string): Promise<Project> {
|
||||
const result = await query<Project>(
|
||||
`INSERT INTO projects (name, description, area_name, location, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, description, area_name, location, status, created_by, created_at, updated_at`,
|
||||
[
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.area_name || null,
|
||||
data.location || null,
|
||||
data.status || 'ACTIVE',
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
* @param id - Project UUID
|
||||
* @param data - Updated project data
|
||||
* @returns Updated project or null if not found
|
||||
*/
|
||||
export async function update(id: string, data: UpdateProjectInput): Promise<Project | null> {
|
||||
// Build SET clause dynamically based on provided fields
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
params.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
updates.push(`description = $${paramIndex}`);
|
||||
params.push(data.description);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.area_name !== undefined) {
|
||||
updates.push(`area_name = $${paramIndex}`);
|
||||
params.push(data.area_name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
updates.push(`location = $${paramIndex}`);
|
||||
params.push(data.location);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex}`);
|
||||
params.push(data.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Always update the updated_at timestamp
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
if (updates.length === 1) {
|
||||
// Only updated_at was added, no actual data to update
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
const result = await query<Project>(
|
||||
`UPDATE projects
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, name, description, area_name, location, status, created_by, created_at, updated_at`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by ID
|
||||
* Checks for dependent meters/concentrators before deletion
|
||||
* @param id - Project UUID
|
||||
* @returns True if deleted, throws error if has dependencies
|
||||
*/
|
||||
export async function deleteProject(id: string): Promise<boolean> {
|
||||
// Check for dependent meters
|
||||
const meterCheck = await query<{ count: string }>(
|
||||
'SELECT COUNT(*) as count FROM meters WHERE project_id = $1',
|
||||
[id]
|
||||
);
|
||||
const meterCount = parseInt(meterCheck.rows[0]?.count || '0', 10);
|
||||
|
||||
if (meterCount > 0) {
|
||||
throw new Error(`Cannot delete project: ${meterCount} meter(s) are associated with this project`);
|
||||
}
|
||||
|
||||
// Check for dependent concentrators
|
||||
const concentratorCheck = await query<{ count: string }>(
|
||||
'SELECT COUNT(*) as count FROM concentrators WHERE project_id = $1',
|
||||
[id]
|
||||
);
|
||||
const concentratorCount = parseInt(concentratorCheck.rows[0]?.count || '0', 10);
|
||||
|
||||
if (concentratorCount > 0) {
|
||||
throw new Error(`Cannot delete project: ${concentratorCount} concentrator(s) are associated with this project`);
|
||||
}
|
||||
|
||||
const result = await query('DELETE FROM projects WHERE id = $1', [id]);
|
||||
|
||||
return (result.rowCount || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project statistics
|
||||
* @param id - Project UUID
|
||||
* @returns Project statistics including meter count, device count, etc.
|
||||
*/
|
||||
export async function getStats(id: string): Promise<ProjectStats | null> {
|
||||
// Verify project exists
|
||||
const project = await getById(id);
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get meter counts
|
||||
const meterStats = await query<{ total: string; active: string; inactive: string }>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'ACTIVE' OR status = 'active') as active,
|
||||
COUNT(*) FILTER (WHERE status = 'INACTIVE' OR status = 'inactive') as inactive
|
||||
FROM meters
|
||||
WHERE project_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Get device count (devices linked to meters in this project)
|
||||
const deviceStats = await query<{ count: string }>(
|
||||
`SELECT COUNT(DISTINCT device_id) as count
|
||||
FROM meters
|
||||
WHERE project_id = $1 AND device_id IS NOT NULL`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Get concentrator count
|
||||
const concentratorStats = await query<{ count: string }>(
|
||||
'SELECT COUNT(*) as count FROM concentrators WHERE project_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
meter_count: parseInt(meterStats.rows[0]?.total || '0', 10),
|
||||
active_meters: parseInt(meterStats.rows[0]?.active || '0', 10),
|
||||
inactive_meters: parseInt(meterStats.rows[0]?.inactive || '0', 10),
|
||||
device_count: parseInt(deviceStats.rows[0]?.count || '0', 10),
|
||||
concentrator_count: parseInt(concentratorStats.rows[0]?.count || '0', 10),
|
||||
};
|
||||
}
|
||||
291
water-api/src/services/reading.service.ts
Normal file
291
water-api/src/services/reading.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
226
water-api/src/services/role.service.ts
Normal file
226
water-api/src/services/role.service.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { query } from '../config/database';
|
||||
import { Role } from '../types';
|
||||
|
||||
/**
|
||||
* Role with user count for extended details
|
||||
*/
|
||||
export interface RoleWithUserCount extends Role {
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles
|
||||
* @returns List of all roles
|
||||
*/
|
||||
export async function getAll(): Promise<Role[]> {
|
||||
const result = await query<Role>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
permissions,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM roles
|
||||
ORDER BY id ASC
|
||||
`
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single role by ID with user count
|
||||
* @param id - Role ID
|
||||
* @returns Role with user count or null if not found
|
||||
*/
|
||||
export async function getById(id: number): Promise<RoleWithUserCount | null> {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
r.description,
|
||||
r.permissions,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
COUNT(u.id)::integer as user_count
|
||||
FROM roles r
|
||||
LEFT JOIN users u ON r.id = u.role_id
|
||||
WHERE r.id = $1
|
||||
GROUP BY r.id
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0] as RoleWithUserCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a role by name
|
||||
* @param name - Role name
|
||||
* @returns Role or null if not found
|
||||
*/
|
||||
export async function getByName(name: string): Promise<Role | null> {
|
||||
const result = await query<Role>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
permissions,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM roles
|
||||
WHERE name = $1
|
||||
`,
|
||||
[name]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new role (admin only)
|
||||
* @param data - Role data
|
||||
* @returns Created role
|
||||
*/
|
||||
export async function create(data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions?: Record<string, unknown> | null;
|
||||
}): Promise<Role> {
|
||||
// Check if role name already exists
|
||||
const existingRole = await getByName(data.name);
|
||||
if (existingRole) {
|
||||
throw new Error('Role name already exists');
|
||||
}
|
||||
|
||||
const result = await query<Role>(
|
||||
`
|
||||
INSERT INTO roles (name, description, permissions)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, description, permissions, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.permissions ? JSON.stringify(data.permissions) : null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a role
|
||||
* @param id - Role ID
|
||||
* @param data - Fields to update
|
||||
* @returns Updated role or null if not found
|
||||
*/
|
||||
export async function update(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
permissions?: Record<string, unknown> | null;
|
||||
}
|
||||
): Promise<Role | null> {
|
||||
// Check if role exists
|
||||
const existingRole = await getById(id);
|
||||
if (!existingRole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If name is being changed, check it's not already in use
|
||||
if (data.name && data.name !== existingRole.name) {
|
||||
const nameRole = await getByName(data.name);
|
||||
if (nameRole) {
|
||||
throw new Error('Role name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// Build UPDATE query dynamically
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
params.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
updates.push(`description = $${paramIndex}`);
|
||||
params.push(data.description);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.permissions !== undefined) {
|
||||
updates.push(`permissions = $${paramIndex}`);
|
||||
params.push(data.permissions ? JSON.stringify(data.permissions) : null);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// No updates to make
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE roles
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, name, description, permissions, created_at, updated_at
|
||||
`;
|
||||
|
||||
const result = await query<Role>(updateQuery, params);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role (only if no users assigned)
|
||||
* @param id - Role ID
|
||||
* @returns True if deleted, false if role not found
|
||||
* @throws Error if users are assigned to the role
|
||||
*/
|
||||
export async function deleteRole(id: number): Promise<boolean> {
|
||||
// Check if role exists and get user count
|
||||
const role = await getById(id);
|
||||
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any users are assigned to this role
|
||||
if (role.user_count > 0) {
|
||||
throw new Error(
|
||||
`Cannot delete role: ${role.user_count} user(s) are currently assigned to this role`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
DELETE FROM roles
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
43
water-api/src/services/tts/index.ts
Normal file
43
water-api/src/services/tts/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* TTS (The Things Stack) Integration Services
|
||||
*
|
||||
* This module provides integration with The Things Stack for LoRaWAN device management.
|
||||
*
|
||||
* Services:
|
||||
* - payloadDecoder: Decode raw LoRaWAN payloads into structured meter readings
|
||||
* - ttsWebhook: Process incoming webhooks from TTS (uplinks, joins, downlinks)
|
||||
* - ttsApi: Make outgoing API calls to TTS (register devices, send downlinks)
|
||||
*/
|
||||
|
||||
// Payload Decoder Service
|
||||
export {
|
||||
decodePayload,
|
||||
decodeWithFallback,
|
||||
DeviceType,
|
||||
type DecodedPayload,
|
||||
} from './payloadDecoder.service';
|
||||
|
||||
// TTS Webhook Service
|
||||
export {
|
||||
processUplink,
|
||||
processJoin,
|
||||
processDownlinkAck,
|
||||
type UplinkProcessingResult,
|
||||
type JoinProcessingResult,
|
||||
type DownlinkAckProcessingResult,
|
||||
} from './ttsWebhook.service';
|
||||
|
||||
// TTS API Service
|
||||
export {
|
||||
isTtsEnabled,
|
||||
registerDevice,
|
||||
deleteDevice,
|
||||
sendDownlink,
|
||||
getDeviceStatus,
|
||||
hexToBase64,
|
||||
base64ToHex,
|
||||
type TtsDeviceRegistration,
|
||||
type TtsDownlinkPayload,
|
||||
type TtsDeviceStatus,
|
||||
type TtsApiResult,
|
||||
} from './ttsApi.service';
|
||||
491
water-api/src/services/tts/payloadDecoder.service.ts
Normal file
491
water-api/src/services/tts/payloadDecoder.service.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import logger from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Decoded payload structure containing water meter readings
|
||||
*/
|
||||
export interface DecodedPayload {
|
||||
/** Current meter reading value (e.g., cubic meters) */
|
||||
readingValue: number | null;
|
||||
/** Battery level percentage (0-100) */
|
||||
batteryLevel: number | null;
|
||||
/** Battery voltage in volts */
|
||||
batteryVoltage: number | null;
|
||||
/** Signal strength (RSSI) */
|
||||
signalStrength: number | null;
|
||||
/** Temperature in Celsius */
|
||||
temperature: number | null;
|
||||
/** Whether there's a leak detected */
|
||||
leakDetected: boolean;
|
||||
/** Whether there's a tamper alert */
|
||||
tamperAlert: boolean;
|
||||
/** Whether the valve is open (for meters with valves) */
|
||||
valveOpen: boolean | null;
|
||||
/** Flow rate in liters per hour */
|
||||
flowRate: number | null;
|
||||
/** Total consumption since last reset */
|
||||
totalConsumption: number | null;
|
||||
/** Device status code */
|
||||
statusCode: number | null;
|
||||
/** Any error codes from the device */
|
||||
errorCodes: string[];
|
||||
/** Additional device-specific data */
|
||||
rawFields: Record<string, unknown>;
|
||||
/** Indicates if decoding was successful */
|
||||
decodingSuccess: boolean;
|
||||
/** Error message if decoding failed */
|
||||
decodingError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device type identifiers for different water meter types
|
||||
*/
|
||||
export enum DeviceType {
|
||||
GENERIC = 'GENERIC',
|
||||
WATER_METER_V1 = 'WATER_METER_V1',
|
||||
WATER_METER_V2 = 'WATER_METER_V2',
|
||||
ULTRASONIC_METER = 'ULTRASONIC_METER',
|
||||
PULSE_COUNTER = 'PULSE_COUNTER',
|
||||
LORAWAN_WATER = 'LORAWAN_WATER',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty decoded payload with default values
|
||||
*/
|
||||
function createEmptyPayload(): DecodedPayload {
|
||||
return {
|
||||
readingValue: null,
|
||||
batteryLevel: null,
|
||||
batteryVoltage: null,
|
||||
signalStrength: null,
|
||||
temperature: null,
|
||||
leakDetected: false,
|
||||
tamperAlert: false,
|
||||
valveOpen: null,
|
||||
flowRate: null,
|
||||
totalConsumption: null,
|
||||
statusCode: null,
|
||||
errorCodes: [],
|
||||
rawFields: {},
|
||||
decodingSuccess: false,
|
||||
decodingError: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode generic water meter payload
|
||||
* Format: [4 bytes reading][2 bytes battery][1 byte status]
|
||||
*/
|
||||
function decodeGenericMeter(buffer: Buffer): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (buffer.length < 7) {
|
||||
payload.decodingError = 'Payload too short for generic meter format';
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reading value: 4 bytes, big-endian, in liters (divide by 1000 for cubic meters)
|
||||
payload.readingValue = buffer.readUInt32BE(0) / 1000;
|
||||
|
||||
// Battery: 2 bytes, big-endian, in millivolts
|
||||
const batteryMv = buffer.readUInt16BE(4);
|
||||
payload.batteryVoltage = batteryMv / 1000;
|
||||
// Estimate battery percentage (assuming 3.6V max, 2.5V min)
|
||||
payload.batteryLevel = Math.min(100, Math.max(0, ((batteryMv - 2500) / 1100) * 100));
|
||||
|
||||
// Status byte
|
||||
const status = buffer.readUInt8(6);
|
||||
payload.leakDetected = (status & 0x01) !== 0;
|
||||
payload.tamperAlert = (status & 0x02) !== 0;
|
||||
payload.valveOpen = (status & 0x04) !== 0 ? true : (status & 0x08) !== 0 ? false : null;
|
||||
payload.statusCode = status;
|
||||
|
||||
payload.decodingSuccess = true;
|
||||
} catch (error) {
|
||||
payload.decodingError = `Failed to decode generic meter: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Water Meter V1 payload
|
||||
* Format: [4 bytes reading][1 byte battery%][2 bytes temp][1 byte status][4 bytes flow]
|
||||
*/
|
||||
function decodeWaterMeterV1(buffer: Buffer): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (buffer.length < 12) {
|
||||
payload.decodingError = 'Payload too short for Water Meter V1 format';
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reading value: 4 bytes, big-endian, in liters
|
||||
payload.readingValue = buffer.readUInt32BE(0) / 1000;
|
||||
|
||||
// Battery percentage: 1 byte (0-100)
|
||||
payload.batteryLevel = buffer.readUInt8(4);
|
||||
|
||||
// Temperature: 2 bytes, big-endian, signed, in 0.1 degrees Celsius
|
||||
payload.temperature = buffer.readInt16BE(5) / 10;
|
||||
|
||||
// Status byte
|
||||
const status = buffer.readUInt8(7);
|
||||
payload.leakDetected = (status & 0x01) !== 0;
|
||||
payload.tamperAlert = (status & 0x02) !== 0;
|
||||
payload.statusCode = status;
|
||||
|
||||
// Error codes
|
||||
if (status & 0x80) payload.errorCodes.push('SENSOR_ERROR');
|
||||
if (status & 0x40) payload.errorCodes.push('MEMORY_ERROR');
|
||||
if (status & 0x20) payload.errorCodes.push('COMMUNICATION_ERROR');
|
||||
|
||||
// Flow rate: 4 bytes, big-endian, in milliliters per hour
|
||||
payload.flowRate = buffer.readUInt32BE(8) / 1000;
|
||||
|
||||
payload.decodingSuccess = true;
|
||||
} catch (error) {
|
||||
payload.decodingError = `Failed to decode Water Meter V1: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Water Meter V2 payload (extended format)
|
||||
* Format: [4 bytes reading][2 bytes battery mV][2 bytes temp][1 byte status][4 bytes flow][4 bytes total]
|
||||
*/
|
||||
function decodeWaterMeterV2(buffer: Buffer): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (buffer.length < 17) {
|
||||
payload.decodingError = 'Payload too short for Water Meter V2 format';
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reading value: 4 bytes, big-endian, in deciliters
|
||||
payload.readingValue = buffer.readUInt32BE(0) / 10000;
|
||||
|
||||
// Battery voltage: 2 bytes, big-endian, in millivolts
|
||||
const batteryMv = buffer.readUInt16BE(4);
|
||||
payload.batteryVoltage = batteryMv / 1000;
|
||||
payload.batteryLevel = Math.min(100, Math.max(0, ((batteryMv - 2500) / 1100) * 100));
|
||||
|
||||
// Temperature: 2 bytes, big-endian, signed, in 0.01 degrees Celsius
|
||||
payload.temperature = buffer.readInt16BE(6) / 100;
|
||||
|
||||
// Status byte
|
||||
const status = buffer.readUInt8(8);
|
||||
payload.leakDetected = (status & 0x01) !== 0;
|
||||
payload.tamperAlert = (status & 0x02) !== 0;
|
||||
payload.valveOpen = (status & 0x04) !== 0;
|
||||
payload.statusCode = status;
|
||||
|
||||
// Flow rate: 4 bytes, big-endian, in milliliters per hour
|
||||
payload.flowRate = buffer.readUInt32BE(9) / 1000;
|
||||
|
||||
// Total consumption: 4 bytes, big-endian, in liters
|
||||
payload.totalConsumption = buffer.readUInt32BE(13) / 1000;
|
||||
|
||||
payload.decodingSuccess = true;
|
||||
} catch (error) {
|
||||
payload.decodingError = `Failed to decode Water Meter V2: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Ultrasonic Meter payload
|
||||
* Format: [4 bytes reading][2 bytes battery][2 bytes signal][1 byte status][4 bytes flow]
|
||||
*/
|
||||
function decodeUltrasonicMeter(buffer: Buffer): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (buffer.length < 13) {
|
||||
payload.decodingError = 'Payload too short for Ultrasonic Meter format';
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reading value: 4 bytes, big-endian, in cubic meters * 1000
|
||||
payload.readingValue = buffer.readUInt32BE(0) / 1000;
|
||||
|
||||
// Battery: 2 bytes, big-endian, percentage * 100
|
||||
payload.batteryLevel = buffer.readUInt16BE(4) / 100;
|
||||
|
||||
// Signal quality: 2 bytes, big-endian
|
||||
payload.signalStrength = buffer.readInt16BE(6);
|
||||
|
||||
// Status byte
|
||||
const status = buffer.readUInt8(8);
|
||||
payload.leakDetected = (status & 0x01) !== 0;
|
||||
payload.tamperAlert = (status & 0x02) !== 0;
|
||||
payload.statusCode = status;
|
||||
|
||||
if (status & 0x10) payload.errorCodes.push('NO_FLOW_DETECTED');
|
||||
if (status & 0x20) payload.errorCodes.push('REVERSE_FLOW');
|
||||
if (status & 0x40) payload.errorCodes.push('AIR_IN_PIPE');
|
||||
|
||||
// Flow rate: 4 bytes, big-endian, in liters per hour
|
||||
payload.flowRate = buffer.readUInt32BE(9) / 1000;
|
||||
|
||||
payload.decodingSuccess = true;
|
||||
} catch (error) {
|
||||
payload.decodingError = `Failed to decode Ultrasonic Meter: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Pulse Counter payload
|
||||
* Format: [4 bytes pulse count][2 bytes battery][1 byte status]
|
||||
*/
|
||||
function decodePulseCounter(buffer: Buffer): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (buffer.length < 7) {
|
||||
payload.decodingError = 'Payload too short for Pulse Counter format';
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pulse count: 4 bytes, big-endian
|
||||
// Assuming 1 pulse = 1 liter, convert to cubic meters
|
||||
const pulseCount = buffer.readUInt32BE(0);
|
||||
payload.readingValue = pulseCount / 1000;
|
||||
payload.rawFields['pulseCount'] = pulseCount;
|
||||
|
||||
// Battery: 2 bytes, big-endian, in millivolts
|
||||
const batteryMv = buffer.readUInt16BE(4);
|
||||
payload.batteryVoltage = batteryMv / 1000;
|
||||
payload.batteryLevel = Math.min(100, Math.max(0, ((batteryMv - 2500) / 1100) * 100));
|
||||
|
||||
// Status byte
|
||||
const status = buffer.readUInt8(6);
|
||||
payload.tamperAlert = (status & 0x01) !== 0;
|
||||
payload.statusCode = status;
|
||||
|
||||
payload.decodingSuccess = true;
|
||||
} catch (error) {
|
||||
payload.decodingError = `Failed to decode Pulse Counter: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode standard LoRaWAN water meter payload
|
||||
* Format varies but typically: [1 byte type][4 bytes reading][remaining data]
|
||||
*/
|
||||
function decodeLoRaWANWater(buffer: Buffer): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (buffer.length < 5) {
|
||||
payload.decodingError = 'Payload too short for LoRaWAN Water format';
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
// First byte indicates message type
|
||||
const msgType = buffer.readUInt8(0);
|
||||
payload.rawFields['messageType'] = msgType;
|
||||
|
||||
// Reading value: 4 bytes, little-endian (common in LoRaWAN), in liters
|
||||
payload.readingValue = buffer.readUInt32LE(1) / 1000;
|
||||
|
||||
// Optional additional data based on message length
|
||||
if (buffer.length >= 7) {
|
||||
// Battery percentage: 1 byte
|
||||
payload.batteryLevel = buffer.readUInt8(5);
|
||||
|
||||
if (buffer.length >= 8) {
|
||||
// Status byte
|
||||
const status = buffer.readUInt8(6);
|
||||
payload.leakDetected = (status & 0x01) !== 0;
|
||||
payload.tamperAlert = (status & 0x02) !== 0;
|
||||
payload.statusCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
payload.decodingSuccess = true;
|
||||
} catch (error) {
|
||||
payload.decodingError = `Failed to decode LoRaWAN Water: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to decode device payloads
|
||||
* Supports multiple device types with different payload formats
|
||||
*
|
||||
* @param rawPayload - Base64 encoded payload string from TTS
|
||||
* @param deviceType - Device type identifier to select appropriate decoder
|
||||
* @returns Decoded payload with meter readings and device status
|
||||
*/
|
||||
export function decodePayload(rawPayload: string, deviceType: string): DecodedPayload {
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
if (!rawPayload) {
|
||||
payload.decodingError = 'Empty payload received';
|
||||
logger.warn('Payload decoding failed: Empty payload');
|
||||
return payload;
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(rawPayload, 'base64');
|
||||
payload.rawFields['rawHex'] = buffer.toString('hex');
|
||||
payload.rawFields['rawLength'] = buffer.length;
|
||||
} catch (error) {
|
||||
payload.decodingError = 'Failed to decode base64 payload';
|
||||
logger.error('Payload decoding failed: Invalid base64', { rawPayload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (buffer.length === 0) {
|
||||
payload.decodingError = 'Decoded payload is empty';
|
||||
logger.warn('Payload decoding failed: Empty buffer after base64 decode');
|
||||
return payload;
|
||||
}
|
||||
|
||||
logger.debug('Decoding payload', {
|
||||
deviceType,
|
||||
payloadHex: buffer.toString('hex'),
|
||||
payloadLength: buffer.length,
|
||||
});
|
||||
|
||||
// Select decoder based on device type
|
||||
const normalizedType = deviceType.toUpperCase().replace(/[-\s]/g, '_');
|
||||
|
||||
let decodedPayload: DecodedPayload;
|
||||
|
||||
switch (normalizedType) {
|
||||
case DeviceType.WATER_METER_V1:
|
||||
decodedPayload = decodeWaterMeterV1(buffer);
|
||||
break;
|
||||
|
||||
case DeviceType.WATER_METER_V2:
|
||||
decodedPayload = decodeWaterMeterV2(buffer);
|
||||
break;
|
||||
|
||||
case DeviceType.ULTRASONIC_METER:
|
||||
decodedPayload = decodeUltrasonicMeter(buffer);
|
||||
break;
|
||||
|
||||
case DeviceType.PULSE_COUNTER:
|
||||
decodedPayload = decodePulseCounter(buffer);
|
||||
break;
|
||||
|
||||
case DeviceType.LORAWAN_WATER:
|
||||
decodedPayload = decodeLoRaWANWater(buffer);
|
||||
break;
|
||||
|
||||
case DeviceType.GENERIC:
|
||||
default:
|
||||
// Try generic decoder for unknown device types
|
||||
decodedPayload = decodeGenericMeter(buffer);
|
||||
break;
|
||||
}
|
||||
|
||||
// Merge raw fields from initial parsing
|
||||
decodedPayload.rawFields = { ...payload.rawFields, ...decodedPayload.rawFields };
|
||||
|
||||
if (decodedPayload.decodingSuccess) {
|
||||
logger.debug('Payload decoded successfully', {
|
||||
deviceType,
|
||||
readingValue: decodedPayload.readingValue,
|
||||
batteryLevel: decodedPayload.batteryLevel,
|
||||
});
|
||||
} else {
|
||||
logger.warn('Payload decoding failed', {
|
||||
deviceType,
|
||||
error: decodedPayload.decodingError,
|
||||
payloadHex: buffer.toString('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
return decodedPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to decode payload using TTS pre-decoded payload if available
|
||||
* Falls back to raw payload decoding if TTS decoding is not available
|
||||
*
|
||||
* @param rawPayload - Base64 encoded payload string
|
||||
* @param ttsDecodedPayload - Pre-decoded payload from TTS (if available)
|
||||
* @param deviceType - Device type identifier
|
||||
* @returns Decoded payload
|
||||
*/
|
||||
export function decodeWithFallback(
|
||||
rawPayload: string,
|
||||
ttsDecodedPayload: Record<string, unknown> | undefined,
|
||||
deviceType: string
|
||||
): DecodedPayload {
|
||||
// If TTS has already decoded the payload, use that data
|
||||
if (ttsDecodedPayload && Object.keys(ttsDecodedPayload).length > 0) {
|
||||
logger.debug('Using TTS pre-decoded payload', { ttsDecodedPayload });
|
||||
|
||||
const payload = createEmptyPayload();
|
||||
|
||||
// Map common TTS decoded fields to our structure
|
||||
if ('reading' in ttsDecodedPayload || 'value' in ttsDecodedPayload || 'volume' in ttsDecodedPayload) {
|
||||
const reading = ttsDecodedPayload.reading ?? ttsDecodedPayload.value ?? ttsDecodedPayload.volume;
|
||||
if (typeof reading === 'number') {
|
||||
payload.readingValue = reading;
|
||||
}
|
||||
}
|
||||
|
||||
if ('battery' in ttsDecodedPayload || 'batteryLevel' in ttsDecodedPayload) {
|
||||
const battery = ttsDecodedPayload.battery ?? ttsDecodedPayload.batteryLevel;
|
||||
if (typeof battery === 'number') {
|
||||
payload.batteryLevel = battery;
|
||||
}
|
||||
}
|
||||
|
||||
if ('temperature' in ttsDecodedPayload) {
|
||||
if (typeof ttsDecodedPayload.temperature === 'number') {
|
||||
payload.temperature = ttsDecodedPayload.temperature;
|
||||
}
|
||||
}
|
||||
|
||||
if ('leak' in ttsDecodedPayload || 'leakDetected' in ttsDecodedPayload) {
|
||||
payload.leakDetected = Boolean(ttsDecodedPayload.leak ?? ttsDecodedPayload.leakDetected);
|
||||
}
|
||||
|
||||
if ('tamper' in ttsDecodedPayload || 'tamperAlert' in ttsDecodedPayload) {
|
||||
payload.tamperAlert = Boolean(ttsDecodedPayload.tamper ?? ttsDecodedPayload.tamperAlert);
|
||||
}
|
||||
|
||||
if ('flow' in ttsDecodedPayload || 'flowRate' in ttsDecodedPayload) {
|
||||
const flow = ttsDecodedPayload.flow ?? ttsDecodedPayload.flowRate;
|
||||
if (typeof flow === 'number') {
|
||||
payload.flowRate = flow;
|
||||
}
|
||||
}
|
||||
|
||||
payload.rawFields = { ...ttsDecodedPayload };
|
||||
payload.decodingSuccess = payload.readingValue !== null;
|
||||
|
||||
if (!payload.decodingSuccess) {
|
||||
// Fall back to raw payload decoding
|
||||
logger.debug('TTS decoded payload missing reading value, falling back to raw decoding');
|
||||
return decodePayload(rawPayload, deviceType);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Fall back to raw payload decoding
|
||||
return decodePayload(rawPayload, deviceType);
|
||||
}
|
||||
|
||||
export default {
|
||||
decodePayload,
|
||||
decodeWithFallback,
|
||||
DeviceType,
|
||||
};
|
||||
518
water-api/src/services/tts/ttsApi.service.ts
Normal file
518
water-api/src/services/tts/ttsApi.service.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import logger from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* TTS API configuration
|
||||
*/
|
||||
interface TtsApiConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
applicationId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device registration payload for TTS
|
||||
*/
|
||||
export interface TtsDeviceRegistration {
|
||||
devEui: string;
|
||||
joinEui: string;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
appKey: string;
|
||||
nwkKey?: string;
|
||||
lorawanVersion?: string;
|
||||
lorawanPhyVersion?: string;
|
||||
frequencyPlanId?: string;
|
||||
supportsClassC?: boolean;
|
||||
supportsJoin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downlink message payload
|
||||
*/
|
||||
export interface TtsDownlinkPayload {
|
||||
fPort: number;
|
||||
frmPayload: string; // Base64 encoded
|
||||
confirmed?: boolean;
|
||||
priority?: 'LOWEST' | 'LOW' | 'BELOW_NORMAL' | 'NORMAL' | 'ABOVE_NORMAL' | 'HIGH' | 'HIGHEST';
|
||||
classBC?: {
|
||||
absoluteTime?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Device status from TTS API
|
||||
*/
|
||||
export interface TtsDeviceStatus {
|
||||
devEui: string;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
lastSeenAt?: string;
|
||||
session?: {
|
||||
devAddr: string;
|
||||
startedAt: string;
|
||||
};
|
||||
macState?: {
|
||||
lastDevStatusReceivedAt?: string;
|
||||
batteryPercentage?: number;
|
||||
margin?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of TTS API operations
|
||||
*/
|
||||
export interface TtsApiResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TTS API configuration from environment
|
||||
*/
|
||||
function getTtsConfig(): TtsApiConfig {
|
||||
return {
|
||||
apiUrl: process.env.TTS_API_URL || '',
|
||||
apiKey: process.env.TTS_API_KEY || '',
|
||||
applicationId: process.env.TTS_APPLICATION_ID || '',
|
||||
enabled: process.env.TTS_ENABLED === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS integration is enabled
|
||||
*/
|
||||
export function isTtsEnabled(): boolean {
|
||||
const config = getTtsConfig();
|
||||
return config.enabled && !!config.apiUrl && !!config.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the TTS API
|
||||
*/
|
||||
async function ttsApiRequest<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<TtsApiResult<T>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
logger.debug('TTS API is disabled, skipping request', { path });
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS integration is disabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.apiUrl || !config.apiKey) {
|
||||
logger.warn('TTS API configuration missing', {
|
||||
hasApiUrl: !!config.apiUrl,
|
||||
hasApiKey: !!config.apiKey,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS API configuration is incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${config.apiUrl}${path}`;
|
||||
|
||||
try {
|
||||
logger.debug('Making TTS API request', { method, path });
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseData: T | undefined;
|
||||
|
||||
try {
|
||||
responseData = responseText ? JSON.parse(responseText) : undefined;
|
||||
} catch {
|
||||
// Response is not JSON
|
||||
logger.debug('TTS API response is not JSON', { responseText: responseText.substring(0, 200) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TTS API request failed', {
|
||||
method,
|
||||
path,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: responseText.substring(0, 500),
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `TTS API error: ${response.status} ${response.statusText}`,
|
||||
statusCode: response.status,
|
||||
data: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('TTS API request successful', { method, path, status: response.status });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
statusCode: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('TTS API request error', {
|
||||
method,
|
||||
path,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `TTS API request failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a device in The Things Stack
|
||||
*
|
||||
* @param device - Device registration details
|
||||
* @returns Result of the registration
|
||||
*/
|
||||
export async function registerDevice(
|
||||
device: TtsDeviceRegistration
|
||||
): Promise<TtsApiResult<TtsDeviceStatus>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping device registration', { devEui: device.devEui });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
devEui: device.devEui,
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('Registering device in TTS', {
|
||||
devEui: device.devEui,
|
||||
deviceId: device.deviceId,
|
||||
});
|
||||
|
||||
const path = `/api/v3/applications/${config.applicationId}/devices`;
|
||||
|
||||
const body = {
|
||||
end_device: {
|
||||
ids: {
|
||||
device_id: device.deviceId,
|
||||
dev_eui: device.devEui.toUpperCase(),
|
||||
join_eui: device.joinEui.toUpperCase(),
|
||||
application_ids: {
|
||||
application_id: config.applicationId,
|
||||
},
|
||||
},
|
||||
name: device.name,
|
||||
description: device.description || '',
|
||||
lorawan_version: device.lorawanVersion || 'MAC_V1_0_3',
|
||||
lorawan_phy_version: device.lorawanPhyVersion || 'PHY_V1_0_3_REV_A',
|
||||
frequency_plan_id: device.frequencyPlanId || 'US_902_928_FSB_2',
|
||||
supports_join: device.supportsJoin !== false,
|
||||
supports_class_c: device.supportsClassC || false,
|
||||
root_keys: {
|
||||
app_key: {
|
||||
key: device.appKey.toUpperCase(),
|
||||
},
|
||||
...(device.nwkKey && {
|
||||
nwk_key: {
|
||||
key: device.nwkKey.toUpperCase(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
field_mask: {
|
||||
paths: [
|
||||
'name',
|
||||
'description',
|
||||
'lorawan_version',
|
||||
'lorawan_phy_version',
|
||||
'frequency_plan_id',
|
||||
'supports_join',
|
||||
'supports_class_c',
|
||||
'root_keys.app_key.key',
|
||||
...(device.nwkKey ? ['root_keys.nwk_key.key'] : []),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ttsApiRequest<TtsDeviceStatus>('POST', path, body);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Device registered in TTS successfully', {
|
||||
devEui: device.devEui,
|
||||
deviceId: device.deviceId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to register device in TTS', {
|
||||
devEui: device.devEui,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device from The Things Stack
|
||||
*
|
||||
* @param devEui - Device EUI
|
||||
* @param deviceId - Device ID in TTS (optional, will use devEui if not provided)
|
||||
* @returns Result of the deletion
|
||||
*/
|
||||
export async function deleteDevice(
|
||||
devEui: string,
|
||||
deviceId?: string
|
||||
): Promise<TtsApiResult<void>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping device deletion', { devEui });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const id = deviceId || devEui.toLowerCase();
|
||||
|
||||
logger.info('Deleting device from TTS', { devEui, deviceId: id });
|
||||
|
||||
const path = `/api/v3/applications/${config.applicationId}/devices/${id}`;
|
||||
|
||||
const result = await ttsApiRequest<void>('DELETE', path);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Device deleted from TTS successfully', { devEui });
|
||||
} else if (result.statusCode === 404) {
|
||||
// Device doesn't exist, consider it a success
|
||||
logger.info('Device not found in TTS, considering deletion successful', { devEui });
|
||||
return { success: true };
|
||||
} else {
|
||||
logger.error('Failed to delete device from TTS', {
|
||||
devEui,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a downlink message to a device
|
||||
*
|
||||
* @param devEui - Device EUI
|
||||
* @param payload - Downlink payload
|
||||
* @param deviceId - Device ID in TTS (optional)
|
||||
* @returns Result of the operation
|
||||
*/
|
||||
export async function sendDownlink(
|
||||
devEui: string,
|
||||
payload: TtsDownlinkPayload,
|
||||
deviceId?: string
|
||||
): Promise<TtsApiResult<{ correlationIds?: string[] }>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping downlink', { devEui });
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS integration is disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const id = deviceId || devEui.toLowerCase();
|
||||
|
||||
logger.info('Sending downlink to device via TTS', {
|
||||
devEui,
|
||||
deviceId: id,
|
||||
fPort: payload.fPort,
|
||||
confirmed: payload.confirmed,
|
||||
});
|
||||
|
||||
const path = `/api/v3/as/applications/${config.applicationId}/devices/${id}/down/push`;
|
||||
|
||||
const body = {
|
||||
downlinks: [
|
||||
{
|
||||
f_port: payload.fPort,
|
||||
frm_payload: payload.frmPayload,
|
||||
confirmed: payload.confirmed || false,
|
||||
priority: payload.priority || 'NORMAL',
|
||||
...(payload.classBC && {
|
||||
class_b_c: payload.classBC,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await ttsApiRequest<{ correlation_ids?: string[] }>('POST', path, body);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Downlink queued successfully', {
|
||||
devEui,
|
||||
correlationIds: result.data?.correlation_ids,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
correlationIds: result.data?.correlation_ids,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
logger.error('Failed to send downlink', {
|
||||
devEui,
|
||||
error: result.error,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
statusCode: result.statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device status from The Things Stack
|
||||
*
|
||||
* @param devEui - Device EUI
|
||||
* @param deviceId - Device ID in TTS (optional)
|
||||
* @returns Device status information
|
||||
*/
|
||||
export async function getDeviceStatus(
|
||||
devEui: string,
|
||||
deviceId?: string
|
||||
): Promise<TtsApiResult<TtsDeviceStatus>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping device status request', { devEui });
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS integration is disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const id = deviceId || devEui.toLowerCase();
|
||||
|
||||
logger.debug('Getting device status from TTS', { devEui, deviceId: id });
|
||||
|
||||
const path = `/api/v3/applications/${config.applicationId}/devices/${id}?field_mask=name,description,created_at,updated_at,session,mac_state`;
|
||||
|
||||
const result = await ttsApiRequest<{
|
||||
ids: {
|
||||
device_id: string;
|
||||
dev_eui: string;
|
||||
};
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
session?: {
|
||||
dev_addr: string;
|
||||
started_at: string;
|
||||
};
|
||||
mac_state?: {
|
||||
last_dev_status_received_at?: string;
|
||||
battery_percentage?: number;
|
||||
margin?: number;
|
||||
};
|
||||
}>('GET', path);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const data = result.data;
|
||||
const status: TtsDeviceStatus = {
|
||||
devEui: data.ids.dev_eui,
|
||||
deviceId: data.ids.device_id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
session: data.session
|
||||
? {
|
||||
devAddr: data.session.dev_addr,
|
||||
startedAt: data.session.started_at,
|
||||
}
|
||||
: undefined,
|
||||
macState: data.mac_state
|
||||
? {
|
||||
lastDevStatusReceivedAt: data.mac_state.last_dev_status_received_at,
|
||||
batteryPercentage: data.mac_state.battery_percentage,
|
||||
margin: data.mac_state.margin,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
logger.debug('Device status retrieved successfully', {
|
||||
devEui,
|
||||
hasSession: !!status.session,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: status,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.statusCode === 404) {
|
||||
logger.info('Device not found in TTS', { devEui });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Device not found in TTS',
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
statusCode: result.statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a hex string to base64 for downlink payloads
|
||||
*/
|
||||
export function hexToBase64(hex: string): string {
|
||||
const cleanHex = hex.replace(/\s/g, '');
|
||||
const buffer = Buffer.from(cleanHex, 'hex');
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to hex for debugging
|
||||
*/
|
||||
export function base64ToHex(base64: string): string {
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
return buffer.toString('hex');
|
||||
}
|
||||
|
||||
export default {
|
||||
isTtsEnabled,
|
||||
registerDevice,
|
||||
deleteDevice,
|
||||
sendDownlink,
|
||||
getDeviceStatus,
|
||||
hexToBase64,
|
||||
base64ToHex,
|
||||
};
|
||||
545
water-api/src/services/tts/ttsWebhook.service.ts
Normal file
545
water-api/src/services/tts/ttsWebhook.service.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { query, getClient } from '../../config/database';
|
||||
import logger from '../../utils/logger';
|
||||
import { decodeWithFallback, DecodedPayload } from './payloadDecoder.service';
|
||||
import {
|
||||
TtsUplinkPayload,
|
||||
TtsJoinPayload,
|
||||
TtsDownlinkAckPayload,
|
||||
} from '../../validators/tts.validator';
|
||||
|
||||
/**
|
||||
* Device record structure from devices table
|
||||
*/
|
||||
interface DeviceRecord {
|
||||
id: number;
|
||||
dev_eui: string;
|
||||
device_type: string;
|
||||
meter_id: number | null;
|
||||
tts_status: string;
|
||||
tts_last_seen: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing an uplink
|
||||
*/
|
||||
export interface UplinkProcessingResult {
|
||||
success: boolean;
|
||||
logId: number | null;
|
||||
deviceId: number | null;
|
||||
meterId: number | null;
|
||||
readingId: number | null;
|
||||
decodedPayload: DecodedPayload | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing a join event
|
||||
*/
|
||||
export interface JoinProcessingResult {
|
||||
success: boolean;
|
||||
deviceId: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing a downlink ack
|
||||
*/
|
||||
export interface DownlinkAckProcessingResult {
|
||||
success: boolean;
|
||||
logId: number | null;
|
||||
deviceId: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log raw uplink payload to the database
|
||||
*/
|
||||
async function logUplinkPayload(payload: TtsUplinkPayload): Promise<number> {
|
||||
const { end_device_ids, uplink_message, received_at } = payload;
|
||||
|
||||
// Extract metadata from first gateway
|
||||
const rxMeta = uplink_message.rx_metadata?.[0];
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO tts_uplink_logs (
|
||||
dev_eui,
|
||||
device_id,
|
||||
application_id,
|
||||
raw_payload,
|
||||
decoded_payload,
|
||||
f_port,
|
||||
f_cnt,
|
||||
rssi,
|
||||
snr,
|
||||
gateway_id,
|
||||
received_at,
|
||||
processed,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, false, NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const values = [
|
||||
end_device_ids.dev_eui.toLowerCase(),
|
||||
end_device_ids.device_id,
|
||||
end_device_ids.application_ids.application_id,
|
||||
uplink_message.frm_payload,
|
||||
uplink_message.decoded_payload ? JSON.stringify(uplink_message.decoded_payload) : null,
|
||||
uplink_message.f_port,
|
||||
uplink_message.f_cnt ?? null,
|
||||
rxMeta?.rssi ?? rxMeta?.channel_rssi ?? null,
|
||||
rxMeta?.snr ?? null,
|
||||
rxMeta?.gateway_ids?.gateway_id ?? null,
|
||||
received_at,
|
||||
];
|
||||
|
||||
const result = await query<{ id: number }>(insertQuery, values);
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find device by dev_eui
|
||||
*/
|
||||
async function findDeviceByDevEui(devEui: string): Promise<DeviceRecord | null> {
|
||||
const selectQuery = `
|
||||
SELECT id, dev_eui, device_type, meter_id, tts_status, tts_last_seen
|
||||
FROM devices
|
||||
WHERE LOWER(dev_eui) = LOWER($1)
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const result = await query<DeviceRecord>(selectQuery, [devEui]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meter reading record
|
||||
*/
|
||||
async function createMeterReading(
|
||||
meterId: number,
|
||||
decodedPayload: DecodedPayload,
|
||||
receivedAt: string
|
||||
): Promise<number> {
|
||||
const insertQuery = `
|
||||
INSERT INTO meter_readings (
|
||||
meter_id,
|
||||
reading_value,
|
||||
reading_date,
|
||||
reading_type,
|
||||
battery_level,
|
||||
signal_strength,
|
||||
is_anomaly,
|
||||
raw_data,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const values = [
|
||||
meterId,
|
||||
decodedPayload.readingValue,
|
||||
receivedAt,
|
||||
'automatic',
|
||||
decodedPayload.batteryLevel,
|
||||
decodedPayload.signalStrength,
|
||||
decodedPayload.leakDetected || decodedPayload.tamperAlert,
|
||||
JSON.stringify(decodedPayload.rawFields),
|
||||
];
|
||||
|
||||
const result = await query<{ id: number }>(insertQuery, values);
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update meter's last reading information
|
||||
*/
|
||||
async function updateMeterLastReading(
|
||||
meterId: number,
|
||||
readingValue: number,
|
||||
readingDate: string
|
||||
): Promise<void> {
|
||||
const updateQuery = `
|
||||
UPDATE meters
|
||||
SET
|
||||
last_reading_value = $1,
|
||||
last_reading_date = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`;
|
||||
|
||||
await query(updateQuery, [readingValue, readingDate, meterId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update device's TTS last seen timestamp
|
||||
*/
|
||||
async function updateDeviceTtsLastSeen(deviceId: number): Promise<void> {
|
||||
const updateQuery = `
|
||||
UPDATE devices
|
||||
SET
|
||||
tts_last_seen = NOW(),
|
||||
last_communication = NOW(),
|
||||
status = 'online',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
await query(updateQuery, [deviceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark uplink log as processed
|
||||
*/
|
||||
async function markLogAsProcessed(
|
||||
logId: number,
|
||||
errorMessage: string | null = null
|
||||
): Promise<void> {
|
||||
const updateQuery = `
|
||||
UPDATE tts_uplink_logs
|
||||
SET
|
||||
processed = true,
|
||||
processed_at = NOW(),
|
||||
error_message = $1
|
||||
WHERE id = $2
|
||||
`;
|
||||
|
||||
await query(updateQuery, [errorMessage, logId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an uplink webhook from TTS
|
||||
*
|
||||
* Steps:
|
||||
* 1. Log raw payload to tts_uplink_logs
|
||||
* 2. Find device by dev_eui
|
||||
* 3. If device found, decode payload
|
||||
* 4. Create meter_reading record
|
||||
* 5. Update meter's last_reading_value and last_reading_at
|
||||
* 6. Update device's tts_last_seen
|
||||
* 7. Mark log as processed
|
||||
*/
|
||||
export async function processUplink(payload: TtsUplinkPayload): Promise<UplinkProcessingResult> {
|
||||
const result: UplinkProcessingResult = {
|
||||
success: false,
|
||||
logId: null,
|
||||
deviceId: null,
|
||||
meterId: null,
|
||||
readingId: null,
|
||||
decodedPayload: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Step 1: Log raw payload
|
||||
logger.info('Processing TTS uplink', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
fPort: payload.uplink_message.f_port,
|
||||
});
|
||||
|
||||
result.logId = await logUplinkPayload(payload);
|
||||
logger.debug('Uplink logged', { logId: result.logId });
|
||||
|
||||
// Step 2: Find device by dev_eui
|
||||
const device = await findDeviceByDevEui(payload.end_device_ids.dev_eui);
|
||||
|
||||
if (!device) {
|
||||
logger.warn('Device not found for uplink', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
logId: result.logId,
|
||||
});
|
||||
await markLogAsProcessed(result.logId, 'Device not found');
|
||||
await client.query('COMMIT');
|
||||
result.error = 'Device not found';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.deviceId = device.id;
|
||||
logger.debug('Device found', { deviceId: device.id, deviceType: device.device_type });
|
||||
|
||||
// Step 3: Decode payload
|
||||
const decodedPayload = decodeWithFallback(
|
||||
payload.uplink_message.frm_payload,
|
||||
payload.uplink_message.decoded_payload,
|
||||
device.device_type
|
||||
);
|
||||
|
||||
result.decodedPayload = decodedPayload;
|
||||
|
||||
if (!decodedPayload.decodingSuccess) {
|
||||
logger.warn('Failed to decode uplink payload', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
error: decodedPayload.decodingError,
|
||||
});
|
||||
await markLogAsProcessed(result.logId, decodedPayload.decodingError);
|
||||
await updateDeviceTtsLastSeen(device.id);
|
||||
await client.query('COMMIT');
|
||||
result.error = decodedPayload.decodingError;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 4: Create meter reading (if device has associated meter)
|
||||
if (device.meter_id && decodedPayload.readingValue !== null) {
|
||||
result.meterId = device.meter_id;
|
||||
|
||||
result.readingId = await createMeterReading(
|
||||
device.meter_id,
|
||||
decodedPayload,
|
||||
payload.received_at
|
||||
);
|
||||
logger.debug('Meter reading created', {
|
||||
readingId: result.readingId,
|
||||
meterId: device.meter_id,
|
||||
value: decodedPayload.readingValue,
|
||||
});
|
||||
|
||||
// Step 5: Update meter's last reading
|
||||
await updateMeterLastReading(
|
||||
device.meter_id,
|
||||
decodedPayload.readingValue,
|
||||
payload.received_at
|
||||
);
|
||||
logger.debug('Meter last reading updated', { meterId: device.meter_id });
|
||||
} else if (!device.meter_id) {
|
||||
logger.debug('Device has no associated meter', { deviceId: device.id });
|
||||
}
|
||||
|
||||
// Step 6: Update device's TTS last seen
|
||||
await updateDeviceTtsLastSeen(device.id);
|
||||
|
||||
// Step 7: Mark log as processed
|
||||
await markLogAsProcessed(result.logId);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
result.success = true;
|
||||
logger.info('Uplink processed successfully', {
|
||||
logId: result.logId,
|
||||
deviceId: result.deviceId,
|
||||
meterId: result.meterId,
|
||||
readingId: result.readingId,
|
||||
readingValue: decodedPayload.readingValue,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Failed to process uplink', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
error: errorMessage,
|
||||
logId: result.logId,
|
||||
});
|
||||
|
||||
// Try to mark log as failed if we have a log ID
|
||||
if (result.logId) {
|
||||
try {
|
||||
await markLogAsProcessed(result.logId, errorMessage);
|
||||
} catch (markError) {
|
||||
logger.error('Failed to mark log as processed', { logId: result.logId });
|
||||
}
|
||||
}
|
||||
|
||||
result.error = errorMessage;
|
||||
return result;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a join webhook from TTS
|
||||
*
|
||||
* Steps:
|
||||
* 1. Find device by dev_eui
|
||||
* 2. Update device tts_status to 'JOINED'
|
||||
* 3. Update tts_last_seen
|
||||
*/
|
||||
export async function processJoin(payload: TtsJoinPayload): Promise<JoinProcessingResult> {
|
||||
const result: JoinProcessingResult = {
|
||||
success: false,
|
||||
deviceId: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info('Processing TTS join event', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
deviceId: payload.end_device_ids.device_id,
|
||||
});
|
||||
|
||||
// Step 1: Find device by dev_eui
|
||||
const device = await findDeviceByDevEui(payload.end_device_ids.dev_eui);
|
||||
|
||||
if (!device) {
|
||||
logger.warn('Device not found for join event', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
});
|
||||
result.error = 'Device not found';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.deviceId = device.id;
|
||||
|
||||
// Step 2 & 3: Update device status and last seen
|
||||
const updateQuery = `
|
||||
UPDATE devices
|
||||
SET
|
||||
tts_status = 'JOINED',
|
||||
tts_last_seen = NOW(),
|
||||
status = 'online',
|
||||
last_communication = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
await query(updateQuery, [device.id]);
|
||||
|
||||
result.success = true;
|
||||
logger.info('Join event processed successfully', {
|
||||
deviceId: device.id,
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Failed to process join event', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
result.error = errorMessage;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log downlink confirmation event
|
||||
*/
|
||||
async function logDownlinkEvent(
|
||||
devEui: string,
|
||||
eventType: 'ack' | 'sent' | 'failed' | 'queued',
|
||||
payload: TtsDownlinkAckPayload
|
||||
): Promise<number> {
|
||||
const insertQuery = `
|
||||
INSERT INTO tts_downlink_logs (
|
||||
dev_eui,
|
||||
device_id,
|
||||
application_id,
|
||||
event_type,
|
||||
f_port,
|
||||
f_cnt,
|
||||
correlation_ids,
|
||||
received_at,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Extract details from the appropriate event field
|
||||
const eventData =
|
||||
payload.downlink_ack ||
|
||||
payload.downlink_sent ||
|
||||
payload.downlink_failed?.downlink ||
|
||||
payload.downlink_queued;
|
||||
|
||||
const values = [
|
||||
devEui.toLowerCase(),
|
||||
payload.end_device_ids.device_id,
|
||||
payload.end_device_ids.application_ids.application_id,
|
||||
eventType,
|
||||
eventData?.f_port ?? null,
|
||||
eventData?.f_cnt ?? null,
|
||||
payload.correlation_ids ? JSON.stringify(payload.correlation_ids) : null,
|
||||
payload.received_at,
|
||||
];
|
||||
|
||||
const result = await query<{ id: number }>(insertQuery, values);
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a downlink acknowledgment webhook from TTS
|
||||
*
|
||||
* Steps:
|
||||
* 1. Log the downlink confirmation
|
||||
* 2. Update device status if needed
|
||||
*/
|
||||
export async function processDownlinkAck(
|
||||
payload: TtsDownlinkAckPayload
|
||||
): Promise<DownlinkAckProcessingResult> {
|
||||
const result: DownlinkAckProcessingResult = {
|
||||
success: false,
|
||||
logId: null,
|
||||
deviceId: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Determine event type
|
||||
let eventType: 'ack' | 'sent' | 'failed' | 'queued' = 'ack';
|
||||
if (payload.downlink_sent) eventType = 'sent';
|
||||
if (payload.downlink_failed) eventType = 'failed';
|
||||
if (payload.downlink_queued) eventType = 'queued';
|
||||
|
||||
logger.info('Processing TTS downlink event', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
eventType,
|
||||
});
|
||||
|
||||
// Step 1: Log the downlink event
|
||||
result.logId = await logDownlinkEvent(
|
||||
payload.end_device_ids.dev_eui,
|
||||
eventType,
|
||||
payload
|
||||
);
|
||||
|
||||
// Step 2: Update device status if needed
|
||||
const device = await findDeviceByDevEui(payload.end_device_ids.dev_eui);
|
||||
|
||||
if (device) {
|
||||
result.deviceId = device.id;
|
||||
|
||||
// Update last seen on any downlink activity
|
||||
await updateDeviceTtsLastSeen(device.id);
|
||||
|
||||
// If downlink failed, log warning but don't change device status
|
||||
if (payload.downlink_failed) {
|
||||
logger.warn('Downlink failed', {
|
||||
deviceId: device.id,
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
error: payload.downlink_failed.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
logger.info('Downlink event processed successfully', {
|
||||
logId: result.logId,
|
||||
deviceId: result.deviceId,
|
||||
eventType,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Failed to process downlink event', {
|
||||
devEui: payload.end_device_ids.dev_eui,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
result.error = errorMessage;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
processUplink,
|
||||
processJoin,
|
||||
processDownlinkAck,
|
||||
};
|
||||
436
water-api/src/services/user.service.ts
Normal file
436
water-api/src/services/user.service.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { query } from '../config/database';
|
||||
import { hashPassword, comparePassword } from '../utils/password';
|
||||
import { User, UserPublic, PaginationParams } from '../types';
|
||||
|
||||
/**
|
||||
* User filter options
|
||||
*/
|
||||
export interface UserFilter {
|
||||
role_id?: number;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User service response with pagination
|
||||
*/
|
||||
export interface PaginatedUsers {
|
||||
users: UserPublic[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users with optional filtering and pagination
|
||||
* @param filters - Optional filters (role_id, is_active)
|
||||
* @param pagination - Optional pagination parameters
|
||||
* @returns Paginated list of users without password_hash
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: UserFilter,
|
||||
pagination?: PaginationParams
|
||||
): Promise<PaginatedUsers> {
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const sortBy = pagination?.sortBy || 'created_at';
|
||||
const sortOrder = pagination?.sortOrder || 'desc';
|
||||
|
||||
// Build WHERE clauses
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters?.role_id !== undefined) {
|
||||
conditions.push(`u.role_id = $${paramIndex}`);
|
||||
params.push(filters.role_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.is_active !== undefined) {
|
||||
conditions.push(`u.is_active = $${paramIndex}`);
|
||||
params.push(filters.is_active);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
conditions.push(
|
||||
`(u.first_name ILIKE $${paramIndex} OR u.last_name ILIKE $${paramIndex} OR u.email ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Validate sortBy to prevent SQL injection
|
||||
const allowedSortColumns = ['created_at', 'updated_at', 'email', 'first_name', 'last_name'];
|
||||
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM users u
|
||||
${whereClause}
|
||||
`;
|
||||
const countResult = await query<{ total: string }>(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
|
||||
// Get users with role name
|
||||
const usersQuery = `
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.role_id,
|
||||
r.name as role_name,
|
||||
r.description as role_description,
|
||||
u.is_active,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
${whereClause}
|
||||
ORDER BY u.${safeSortBy} ${safeSortOrder}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
const usersResult = await query(usersQuery, [...params, limit, offset]);
|
||||
|
||||
const users: UserPublic[] = usersResult.rows.map((row) => ({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
first_name: row.first_name,
|
||||
last_name: row.last_name,
|
||||
role_id: row.role_id,
|
||||
role: row.role_name
|
||||
? {
|
||||
id: row.role_id,
|
||||
name: row.role_name,
|
||||
description: row.role_description,
|
||||
permissions: [],
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
: undefined,
|
||||
is_active: row.is_active,
|
||||
last_login: row.last_login,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
users,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single user by ID without password_hash, include role name
|
||||
* @param id - User ID
|
||||
* @returns User without password_hash or null if not found
|
||||
*/
|
||||
export async function getById(id: number): Promise<UserPublic | null> {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.role_id,
|
||||
r.name as role_name,
|
||||
r.description as role_description,
|
||||
r.permissions as role_permissions,
|
||||
u.is_active,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
first_name: row.first_name,
|
||||
last_name: row.last_name,
|
||||
role_id: row.role_id,
|
||||
role: row.role_name
|
||||
? {
|
||||
id: row.role_id,
|
||||
name: row.role_name,
|
||||
description: row.role_description,
|
||||
permissions: row.role_permissions || [],
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
: undefined,
|
||||
is_active: row.is_active,
|
||||
last_login: row.last_login,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by email (internal use, includes password_hash)
|
||||
* @param email - User email
|
||||
* @returns Full user record or null if not found
|
||||
*/
|
||||
export async function getByEmail(email: string): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
`
|
||||
SELECT
|
||||
u.*,
|
||||
r.name as role_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.email = $1
|
||||
`,
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with hashed password
|
||||
* @param data - User data including password
|
||||
* @returns Created user without password_hash
|
||||
*/
|
||||
export async function create(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role_id: number;
|
||||
is_active?: boolean;
|
||||
}): Promise<UserPublic> {
|
||||
// Check if email already exists
|
||||
const existingUser = await getByEmail(data.email);
|
||||
if (existingUser) {
|
||||
throw new Error('Email already in use');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const password_hash = await hashPassword(data.password);
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
INSERT INTO users (email, password_hash, first_name, last_name, role_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, email, first_name, last_name, role_id, is_active, last_login, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
data.email.toLowerCase(),
|
||||
password_hash,
|
||||
data.first_name,
|
||||
data.last_name,
|
||||
data.role_id,
|
||||
data.is_active ?? true,
|
||||
]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Fetch complete user with role
|
||||
return (await getById(user.id)) as UserPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user (hash password if changed)
|
||||
* @param id - User ID
|
||||
* @param data - Fields to update
|
||||
* @returns Updated user without password_hash
|
||||
*/
|
||||
export async function update(
|
||||
id: number,
|
||||
data: {
|
||||
email?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
role_id?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
): Promise<UserPublic | null> {
|
||||
// Check if user exists
|
||||
const existingUser = await getById(id);
|
||||
if (!existingUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If email is being changed, check it's not already in use
|
||||
if (data.email && data.email.toLowerCase() !== existingUser.email) {
|
||||
const emailUser = await getByEmail(data.email);
|
||||
if (emailUser) {
|
||||
throw new Error('Email already in use');
|
||||
}
|
||||
}
|
||||
|
||||
// Build UPDATE query dynamically
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.email !== undefined) {
|
||||
updates.push(`email = $${paramIndex}`);
|
||||
params.push(data.email.toLowerCase());
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.first_name !== undefined) {
|
||||
updates.push(`first_name = $${paramIndex}`);
|
||||
params.push(data.first_name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.last_name !== undefined) {
|
||||
updates.push(`last_name = $${paramIndex}`);
|
||||
params.push(data.last_name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.role_id !== undefined) {
|
||||
updates.push(`role_id = $${paramIndex}`);
|
||||
params.push(data.role_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.is_active !== undefined) {
|
||||
updates.push(`is_active = $${paramIndex}`);
|
||||
params.push(data.is_active);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE users
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
await query(updateQuery, params);
|
||||
|
||||
return await getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a user by setting is_active = false
|
||||
* @param id - User ID
|
||||
* @returns True if deleted, false if user not found
|
||||
*/
|
||||
export async function deleteUser(id: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE users
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password after verifying current password
|
||||
* @param id - User ID
|
||||
* @param currentPassword - Current password for verification
|
||||
* @param newPassword - New password to set
|
||||
* @returns True if password changed, throws error if verification fails
|
||||
*/
|
||||
export async function changePassword(
|
||||
id: number,
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<boolean> {
|
||||
// Get user with password hash
|
||||
const result = await query<{ password_hash: string }>(
|
||||
`SELECT password_hash FROM users WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await comparePassword(currentPassword, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password and update
|
||||
const newPasswordHash = await hashPassword(newPassword);
|
||||
|
||||
await query(
|
||||
`
|
||||
UPDATE users
|
||||
SET password_hash = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`,
|
||||
[newPasswordHash, id]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last_login timestamp for a user
|
||||
* @param id - User ID
|
||||
* @returns True if updated, false if user not found
|
||||
*/
|
||||
export async function updateLastLogin(id: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE users
|
||||
SET last_login = NOW(), updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
342
water-api/src/types/index.ts
Normal file
342
water-api/src/types/index.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
// ============================================
|
||||
// Base Types
|
||||
// ============================================
|
||||
|
||||
export interface Timestamps {
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User & Authentication Types
|
||||
// ============================================
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role_id: number;
|
||||
role?: Role;
|
||||
is_active: boolean;
|
||||
last_login: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role_id: number;
|
||||
role?: Role;
|
||||
is_active: boolean;
|
||||
last_login: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: number;
|
||||
email: string;
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Project Types
|
||||
// ============================================
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
is_active: boolean;
|
||||
created_by: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Infrastructure Types
|
||||
// ============================================
|
||||
|
||||
export interface Concentrator {
|
||||
id: number;
|
||||
project_id: number;
|
||||
project?: Project;
|
||||
name: string;
|
||||
serial_number: string;
|
||||
model: string | null;
|
||||
firmware_version: string | null;
|
||||
ip_address: string | null;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
status: 'online' | 'offline' | 'maintenance' | 'unknown';
|
||||
last_communication: Date | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Gateway {
|
||||
id: number;
|
||||
concentrator_id: number;
|
||||
concentrator?: Concentrator;
|
||||
name: string;
|
||||
serial_number: string;
|
||||
model: string | null;
|
||||
firmware_version: string | null;
|
||||
mac_address: string | null;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
status: 'online' | 'offline' | 'maintenance' | 'unknown';
|
||||
last_communication: Date | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
gateway_id: number;
|
||||
gateway?: Gateway;
|
||||
name: string;
|
||||
serial_number: string;
|
||||
device_type: string;
|
||||
model: string | null;
|
||||
firmware_version: string | null;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
status: 'online' | 'offline' | 'maintenance' | 'unknown';
|
||||
last_communication: Date | null;
|
||||
battery_level: number | null;
|
||||
signal_strength: number | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Meter Types
|
||||
// ============================================
|
||||
|
||||
export type MeterStatus = 'active' | 'inactive' | 'maintenance' | 'faulty' | 'replaced';
|
||||
|
||||
export interface Meter {
|
||||
id: number;
|
||||
device_id: number;
|
||||
device?: Device;
|
||||
meter_number: string;
|
||||
customer_name: string | null;
|
||||
customer_address: string | null;
|
||||
customer_phone: string | null;
|
||||
customer_email: string | null;
|
||||
meter_type: 'residential' | 'commercial' | 'industrial';
|
||||
installation_date: Date | null;
|
||||
last_reading_date: Date | null;
|
||||
last_reading_value: number | null;
|
||||
status: MeterStatus;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface MeterReading {
|
||||
id: number;
|
||||
meter_id: number;
|
||||
meter?: Meter;
|
||||
reading_value: number;
|
||||
reading_date: Date;
|
||||
consumption: number | null;
|
||||
unit: string;
|
||||
reading_type: 'automatic' | 'manual' | 'estimated';
|
||||
battery_level: number | null;
|
||||
signal_strength: number | null;
|
||||
is_anomaly: boolean;
|
||||
anomaly_type: string | null;
|
||||
raw_data: Record<string, unknown> | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Response Types
|
||||
// ============================================
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
errors?: ValidationError[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Filter Types
|
||||
// ============================================
|
||||
|
||||
export interface MeterFilter {
|
||||
project_id?: number;
|
||||
concentrator_id?: number;
|
||||
gateway_id?: number;
|
||||
device_id?: number;
|
||||
status?: MeterStatus;
|
||||
meter_type?: 'residential' | 'commercial' | 'industrial';
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ReadingFilter {
|
||||
meter_id?: number;
|
||||
start_date?: Date;
|
||||
end_date?: Date;
|
||||
reading_type?: 'automatic' | 'manual' | 'estimated';
|
||||
is_anomaly?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Dashboard & Statistics Types
|
||||
// ============================================
|
||||
|
||||
export interface DashboardStats {
|
||||
total_projects: number;
|
||||
total_concentrators: number;
|
||||
total_gateways: number;
|
||||
total_devices: number;
|
||||
total_meters: number;
|
||||
active_meters: number;
|
||||
total_readings_today: number;
|
||||
total_consumption_today: number;
|
||||
devices_online: number;
|
||||
devices_offline: number;
|
||||
}
|
||||
|
||||
export interface ConsumptionSummary {
|
||||
period: string;
|
||||
total_consumption: number;
|
||||
average_consumption: number;
|
||||
max_consumption: number;
|
||||
min_consumption: number;
|
||||
reading_count: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TTS (The Things Stack) Types
|
||||
// ============================================
|
||||
|
||||
export type TtsDeviceStatus = 'PENDING' | 'REGISTERED' | 'JOINED' | 'ACTIVE' | 'INACTIVE' | 'ERROR';
|
||||
|
||||
export interface TtsDevice extends Device {
|
||||
dev_eui: string;
|
||||
join_eui: string | null;
|
||||
app_key: string | null;
|
||||
nwk_key: string | null;
|
||||
tts_device_id: string | null;
|
||||
tts_application_id: string | null;
|
||||
tts_status: TtsDeviceStatus;
|
||||
tts_last_seen: Date | null;
|
||||
tts_registered_at: Date | null;
|
||||
}
|
||||
|
||||
export interface TtsUplinkLog {
|
||||
id: number;
|
||||
dev_eui: string;
|
||||
device_id: number | null;
|
||||
application_id: string;
|
||||
raw_payload: string;
|
||||
decoded_payload: Record<string, unknown> | null;
|
||||
f_port: number;
|
||||
f_cnt: number | null;
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
gateway_id: string | null;
|
||||
received_at: Date;
|
||||
processed: boolean;
|
||||
processed_at: Date | null;
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface TtsDownlinkLog {
|
||||
id: number;
|
||||
dev_eui: string;
|
||||
device_id: number | null;
|
||||
application_id: string;
|
||||
event_type: 'ack' | 'sent' | 'failed' | 'queued';
|
||||
f_port: number | null;
|
||||
f_cnt: number | null;
|
||||
correlation_ids: string[] | null;
|
||||
received_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface TtsWebhookPayload {
|
||||
end_device_ids: {
|
||||
device_id: string;
|
||||
dev_eui: string;
|
||||
join_eui?: string;
|
||||
application_ids: {
|
||||
application_id: string;
|
||||
};
|
||||
};
|
||||
correlation_ids?: string[];
|
||||
received_at: string;
|
||||
}
|
||||
0
water-api/src/utils/.gitkeep
Normal file
0
water-api/src/utils/.gitkeep
Normal file
137
water-api/src/utils/jwt.ts
Normal file
137
water-api/src/utils/jwt.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import logger from './logger';
|
||||
|
||||
interface TokenPayload {
|
||||
id: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an access token
|
||||
* @param payload - Data to encode in the token
|
||||
* @returns Signed JWT access token
|
||||
*/
|
||||
export const generateAccessToken = (payload: TokenPayload): string => {
|
||||
const options: SignOptions = {
|
||||
expiresIn: config.jwt.accessTokenExpiresIn as SignOptions['expiresIn'],
|
||||
algorithm: 'HS256',
|
||||
};
|
||||
|
||||
try {
|
||||
const token = jwt.sign(payload, config.jwt.accessTokenSecret, options);
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.error('Error generating access token', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new Error('Failed to generate access token');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a refresh token
|
||||
* @param payload - Data to encode in the token
|
||||
* @returns Signed JWT refresh token
|
||||
*/
|
||||
export const generateRefreshToken = (payload: TokenPayload): string => {
|
||||
const options: SignOptions = {
|
||||
expiresIn: config.jwt.refreshTokenExpiresIn as SignOptions['expiresIn'],
|
||||
algorithm: 'HS256',
|
||||
};
|
||||
|
||||
try {
|
||||
const token = jwt.sign(payload, config.jwt.refreshTokenSecret, options);
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.error('Error generating refresh token', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new Error('Failed to generate refresh token');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify an access token
|
||||
* @param token - JWT access token to verify
|
||||
* @returns Decoded payload if valid, null if invalid or expired
|
||||
*/
|
||||
export const verifyAccessToken = (token: string): JwtPayload | null => {
|
||||
const options: VerifyOptions = {
|
||||
algorithms: ['HS256'],
|
||||
};
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
config.jwt.accessTokenSecret,
|
||||
options
|
||||
) as JwtPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
logger.debug('Access token expired');
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
logger.debug('Invalid access token', {
|
||||
error: error.message,
|
||||
});
|
||||
} else {
|
||||
logger.error('Error verifying access token', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a refresh token
|
||||
* @param token - JWT refresh token to verify
|
||||
* @returns Decoded payload if valid, null if invalid or expired
|
||||
*/
|
||||
export const verifyRefreshToken = (token: string): JwtPayload | null => {
|
||||
const options: VerifyOptions = {
|
||||
algorithms: ['HS256'],
|
||||
};
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
config.jwt.refreshTokenSecret,
|
||||
options
|
||||
) as JwtPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
logger.debug('Refresh token expired');
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
logger.debug('Invalid refresh token', {
|
||||
error: error.message,
|
||||
});
|
||||
} else {
|
||||
logger.error('Error verifying refresh token', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a token without verification (for debugging)
|
||||
* @param token - JWT token to decode
|
||||
* @returns Decoded payload or null
|
||||
*/
|
||||
export const decodeToken = (token: string): JwtPayload | null => {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as JwtPayload | null;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
logger.error('Error decoding token', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
39
water-api/src/utils/logger.ts
Normal file
39
water-api/src/utils/logger.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
||||
|
||||
const logFormat = printf(({ level, message, timestamp, stack }) => {
|
||||
return `${timestamp} [${level}]: ${stack || message}`;
|
||||
});
|
||||
|
||||
const getLogLevel = (): string => {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
switch (env) {
|
||||
case 'production':
|
||||
return 'warn';
|
||||
case 'test':
|
||||
return 'error';
|
||||
default:
|
||||
return 'debug';
|
||||
}
|
||||
};
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: getLogLevel(),
|
||||
format: combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' })
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: combine(
|
||||
colorize({ all: true }),
|
||||
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
logFormat
|
||||
),
|
||||
}),
|
||||
],
|
||||
exitOnError: false,
|
||||
});
|
||||
|
||||
export default logger;
|
||||
43
water-api/src/utils/password.ts
Normal file
43
water-api/src/utils/password.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import logger from './logger';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
* @param password - Plain text password to hash
|
||||
* @returns Hashed password
|
||||
*/
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(SALT_ROUNDS);
|
||||
const hashedPassword = await bcrypt.hash(password, salt);
|
||||
return hashedPassword;
|
||||
} catch (error) {
|
||||
logger.error('Error hashing password', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new Error('Failed to hash password');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare a plain text password with a hashed password
|
||||
* @param password - Plain text password to compare
|
||||
* @param hash - Hashed password to compare against
|
||||
* @returns True if passwords match, false otherwise
|
||||
*/
|
||||
export const comparePassword = async (
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const isMatch = await bcrypt.compare(password, hash);
|
||||
return isMatch;
|
||||
} catch (error) {
|
||||
logger.error('Error comparing passwords', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new Error('Failed to compare passwords');
|
||||
}
|
||||
};
|
||||
0
water-api/src/validators/.gitkeep
Normal file
0
water-api/src/validators/.gitkeep
Normal file
66
water-api/src/validators/auth.validator.ts
Normal file
66
water-api/src/validators/auth.validator.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Schema for login request validation
|
||||
* - email: must be valid email format
|
||||
* - password: minimum 6 characters
|
||||
*/
|
||||
export const loginSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: 'Email is required' })
|
||||
.email('Invalid email format'),
|
||||
password: z
|
||||
.string({ required_error: 'Password is required' })
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for refresh token request validation
|
||||
* - refreshToken: required string
|
||||
*/
|
||||
export const refreshSchema = z.object({
|
||||
refreshToken: z
|
||||
.string({ required_error: 'Refresh token is required' })
|
||||
.min(1, 'Refresh token cannot be empty'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type RefreshInput = z.infer<typeof refreshSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace body with validated and typed data
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares
|
||||
*/
|
||||
export const validateLogin = validate(loginSchema);
|
||||
export const validateRefresh = validate(refreshSchema);
|
||||
132
water-api/src/validators/concentrator.validator.ts
Normal file
132
water-api/src/validators/concentrator.validator.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Schema for creating a concentrator
|
||||
* - serial_number: required, unique identifier
|
||||
* - name: optional display name
|
||||
* - project_id: required UUID, links to project
|
||||
* - location: optional location description
|
||||
* - status: optional status enum
|
||||
* - ip_address: optional IP address
|
||||
* - firmware_version: optional firmware version
|
||||
*/
|
||||
export const createConcentratorSchema = z.object({
|
||||
serial_number: z
|
||||
.string({ required_error: 'Serial number is required' })
|
||||
.min(1, 'Serial number cannot be empty'),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.optional(),
|
||||
project_id: z
|
||||
.string({ required_error: 'Project ID is required' })
|
||||
.uuid('Project ID must be a valid UUID'),
|
||||
location: z
|
||||
.string()
|
||||
.optional(),
|
||||
type: z
|
||||
.enum(['LORA', 'LORAWAN', 'GRANDES'])
|
||||
.optional()
|
||||
.default('LORA'),
|
||||
status: z
|
||||
.enum(['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'OFFLINE'])
|
||||
.optional()
|
||||
.default('ACTIVE'),
|
||||
ip_address: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(val => (!val || val === '') ? null : val)
|
||||
.refine(val => val === null || /^(\d{1,3}\.){3}\d{1,3}$/.test(val), {
|
||||
message: 'Invalid IP address format',
|
||||
}),
|
||||
firmware_version: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(val => (!val || val === '') ? null : val),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a concentrator
|
||||
* All fields are optional
|
||||
*/
|
||||
export const updateConcentratorSchema = z.object({
|
||||
serial_number: z
|
||||
.string()
|
||||
.min(1, 'Serial number cannot be empty')
|
||||
.optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.optional()
|
||||
.nullable(),
|
||||
project_id: z
|
||||
.string()
|
||||
.uuid('Project ID must be a valid UUID')
|
||||
.optional(),
|
||||
location: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
type: z
|
||||
.enum(['LORA', 'LORAWAN', 'GRANDES'])
|
||||
.optional(),
|
||||
status: z
|
||||
.enum(['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'OFFLINE'])
|
||||
.optional(),
|
||||
ip_address: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(val => (!val || val === '') ? null : val)
|
||||
.refine(val => val === null || val === undefined || /^(\d{1,3}\.){3}\d{1,3}$/.test(val), {
|
||||
message: 'Invalid IP address format',
|
||||
}),
|
||||
firmware_version: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(val => (!val || val === '') ? null : val),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateConcentratorInput = z.infer<typeof createConcentratorSchema>;
|
||||
export type UpdateConcentratorInput = z.infer<typeof updateConcentratorSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares
|
||||
*/
|
||||
export const validateCreateConcentrator = validate(createConcentratorSchema);
|
||||
export const validateUpdateConcentrator = validate(updateConcentratorSchema);
|
||||
144
water-api/src/validators/device.validator.ts
Normal file
144
water-api/src/validators/device.validator.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Schema for creating a device
|
||||
* - dev_eui: required, LoRaWAN DevEUI
|
||||
* - name: optional display name
|
||||
* - device_type: optional device type classification
|
||||
* - project_id: required UUID, links to project
|
||||
* - gateway_id: optional UUID, links to gateway
|
||||
* - status: optional status enum
|
||||
* - tts_device_id: optional The Things Stack device ID
|
||||
* - app_key: optional LoRaWAN AppKey
|
||||
* - join_eui: optional LoRaWAN JoinEUI/AppEUI
|
||||
*/
|
||||
export const createDeviceSchema = z.object({
|
||||
dev_eui: z
|
||||
.string({ required_error: 'DevEUI is required' })
|
||||
.min(1, 'DevEUI cannot be empty')
|
||||
.regex(/^[0-9A-Fa-f]{16}$/, 'DevEUI must be a 16-character hexadecimal string'),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.optional(),
|
||||
device_type: z
|
||||
.string()
|
||||
.min(1, 'Device type cannot be empty')
|
||||
.optional()
|
||||
.nullable(),
|
||||
project_id: z
|
||||
.string({ required_error: 'Project ID is required' })
|
||||
.uuid('Project ID must be a valid UUID'),
|
||||
gateway_id: z
|
||||
.string()
|
||||
.uuid('Gateway ID must be a valid UUID')
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z
|
||||
.enum(['online', 'offline', 'maintenance', 'unknown'])
|
||||
.optional()
|
||||
.default('unknown'),
|
||||
tts_device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
app_key: z
|
||||
.string()
|
||||
.regex(/^[0-9A-Fa-f]{32}$/, 'AppKey must be a 32-character hexadecimal string')
|
||||
.optional()
|
||||
.nullable(),
|
||||
join_eui: z
|
||||
.string()
|
||||
.regex(/^[0-9A-Fa-f]{16}$/, 'JoinEUI must be a 16-character hexadecimal string')
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a device
|
||||
* All fields are optional
|
||||
*/
|
||||
export const updateDeviceSchema = z.object({
|
||||
dev_eui: z
|
||||
.string()
|
||||
.min(1, 'DevEUI cannot be empty')
|
||||
.regex(/^[0-9A-Fa-f]{16}$/, 'DevEUI must be a 16-character hexadecimal string')
|
||||
.optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.optional()
|
||||
.nullable(),
|
||||
device_type: z
|
||||
.string()
|
||||
.min(1, 'Device type cannot be empty')
|
||||
.optional()
|
||||
.nullable(),
|
||||
project_id: z
|
||||
.string()
|
||||
.uuid('Project ID must be a valid UUID')
|
||||
.optional(),
|
||||
gateway_id: z
|
||||
.string()
|
||||
.uuid('Gateway ID must be a valid UUID')
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z
|
||||
.enum(['online', 'offline', 'maintenance', 'unknown'])
|
||||
.optional(),
|
||||
tts_device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
app_key: z
|
||||
.string()
|
||||
.regex(/^[0-9A-Fa-f]{32}$/, 'AppKey must be a 32-character hexadecimal string')
|
||||
.optional()
|
||||
.nullable(),
|
||||
join_eui: z
|
||||
.string()
|
||||
.regex(/^[0-9A-Fa-f]{16}$/, 'JoinEUI must be a 16-character hexadecimal string')
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateDeviceInput = z.infer<typeof createDeviceSchema>;
|
||||
export type UpdateDeviceInput = z.infer<typeof updateDeviceSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares
|
||||
*/
|
||||
export const validateCreateDevice = validate(createDeviceSchema);
|
||||
export const validateUpdateDevice = validate(updateDeviceSchema);
|
||||
118
water-api/src/validators/gateway.validator.ts
Normal file
118
water-api/src/validators/gateway.validator.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Schema for creating a gateway
|
||||
* - gateway_id: required, unique identifier
|
||||
* - name: optional display name
|
||||
* - project_id: required UUID, links to project
|
||||
* - concentrator_id: optional UUID, links to concentrator
|
||||
* - location: optional location description
|
||||
* - status: optional status enum
|
||||
* - tts_gateway_id: optional The Things Stack gateway ID
|
||||
*/
|
||||
export const createGatewaySchema = z.object({
|
||||
gateway_id: z
|
||||
.string({ required_error: 'Gateway ID is required' })
|
||||
.min(1, 'Gateway ID cannot be empty'),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.optional(),
|
||||
project_id: z
|
||||
.string({ required_error: 'Project ID is required' })
|
||||
.uuid('Project ID must be a valid UUID'),
|
||||
concentrator_id: z
|
||||
.string()
|
||||
.uuid('Concentrator ID must be a valid UUID')
|
||||
.optional()
|
||||
.nullable(),
|
||||
location: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z
|
||||
.enum(['online', 'offline', 'maintenance', 'unknown'])
|
||||
.optional()
|
||||
.default('unknown'),
|
||||
tts_gateway_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a gateway
|
||||
* All fields are optional
|
||||
*/
|
||||
export const updateGatewaySchema = z.object({
|
||||
gateway_id: z
|
||||
.string()
|
||||
.min(1, 'Gateway ID cannot be empty')
|
||||
.optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.optional()
|
||||
.nullable(),
|
||||
project_id: z
|
||||
.string()
|
||||
.uuid('Project ID must be a valid UUID')
|
||||
.optional(),
|
||||
concentrator_id: z
|
||||
.string()
|
||||
.uuid('Concentrator ID must be a valid UUID')
|
||||
.optional()
|
||||
.nullable(),
|
||||
location: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z
|
||||
.enum(['online', 'offline', 'maintenance', 'unknown'])
|
||||
.optional(),
|
||||
tts_gateway_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateGatewayInput = z.infer<typeof createGatewaySchema>;
|
||||
export type UpdateGatewayInput = z.infer<typeof updateGatewaySchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares
|
||||
*/
|
||||
export const validateCreateGateway = validate(createGatewaySchema);
|
||||
export const validateUpdateGateway = validate(updateGatewaySchema);
|
||||
161
water-api/src/validators/meter.validator.ts
Normal file
161
water-api/src/validators/meter.validator.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Meter status enum values
|
||||
*/
|
||||
export const MeterStatus = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
INACTIVE: 'INACTIVE',
|
||||
MAINTENANCE: 'MAINTENANCE',
|
||||
FAULTY: 'FAULTY',
|
||||
REPLACED: 'REPLACED',
|
||||
} as const;
|
||||
|
||||
export type MeterStatusType = (typeof MeterStatus)[keyof typeof MeterStatus];
|
||||
|
||||
/**
|
||||
* UUID validation regex
|
||||
*/
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Meter type for LORA devices
|
||||
*/
|
||||
export const LoraType = {
|
||||
LORA: 'LORA',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Schema for creating a new meter
|
||||
* - serial_number: required, unique identifier
|
||||
* - name: required string
|
||||
* - concentrator_id: required UUID (meters belong to concentrators)
|
||||
* - location: optional string
|
||||
* - type: optional, defaults to LORA
|
||||
* - status: optional enum, defaults to ACTIVE
|
||||
* - installation_date: optional date string
|
||||
*/
|
||||
export const createMeterSchema = z.object({
|
||||
serial_number: z
|
||||
.string({ required_error: 'Serial number is required' })
|
||||
.min(1, 'Serial number cannot be empty')
|
||||
.max(100, 'Serial number must be at most 100 characters'),
|
||||
meter_id: z
|
||||
.string()
|
||||
.max(100, 'Meter ID must be at most 100 characters')
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(val => (!val || val === '') ? null : val),
|
||||
name: z
|
||||
.string({ required_error: 'Name is required' })
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(255, 'Name must be at most 255 characters'),
|
||||
concentrator_id: z
|
||||
.string({ required_error: 'Concentrator ID is required' })
|
||||
.regex(uuidRegex, 'Concentrator ID must be a valid UUID'),
|
||||
location: z
|
||||
.string()
|
||||
.max(500, 'Location must be at most 500 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
type: z
|
||||
.string()
|
||||
.max(50, 'Type must be at most 50 characters')
|
||||
.default('LORA')
|
||||
.optional(),
|
||||
status: z
|
||||
.enum([MeterStatus.ACTIVE, MeterStatus.INACTIVE, MeterStatus.MAINTENANCE, MeterStatus.FAULTY, MeterStatus.REPLACED])
|
||||
.default(MeterStatus.ACTIVE)
|
||||
.optional(),
|
||||
installation_date: z
|
||||
.string()
|
||||
.datetime({ message: 'Installation date must be a valid ISO date string' })
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a meter
|
||||
* All fields are optional
|
||||
*/
|
||||
export const updateMeterSchema = z.object({
|
||||
serial_number: z
|
||||
.string()
|
||||
.min(1, 'Serial number cannot be empty')
|
||||
.max(100, 'Serial number must be at most 100 characters')
|
||||
.optional(),
|
||||
meter_id: z
|
||||
.string()
|
||||
.max(100, 'Meter ID must be at most 100 characters')
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(val => (!val || val === '') ? null : val),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(255, 'Name must be at most 255 characters')
|
||||
.optional(),
|
||||
concentrator_id: z
|
||||
.string()
|
||||
.regex(uuidRegex, 'Concentrator ID must be a valid UUID')
|
||||
.optional(),
|
||||
location: z
|
||||
.string()
|
||||
.max(500, 'Location must be at most 500 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
type: z
|
||||
.string()
|
||||
.max(50, 'Type must be at most 50 characters')
|
||||
.optional(),
|
||||
status: z
|
||||
.enum([MeterStatus.ACTIVE, MeterStatus.INACTIVE, MeterStatus.MAINTENANCE, MeterStatus.FAULTY, MeterStatus.REPLACED])
|
||||
.optional(),
|
||||
installation_date: z
|
||||
.string()
|
||||
.datetime({ message: 'Installation date must be a valid ISO date string' })
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateMeterInput = z.infer<typeof createMeterSchema>;
|
||||
export type UpdateMeterInput = z.infer<typeof updateMeterSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace body with validated and typed data
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares for meters
|
||||
*/
|
||||
export const validateCreateMeter = validate(createMeterSchema);
|
||||
export const validateUpdateMeter = validate(updateMeterSchema);
|
||||
118
water-api/src/validators/project.validator.ts
Normal file
118
water-api/src/validators/project.validator.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Project status enum values
|
||||
*/
|
||||
export const ProjectStatus = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
INACTIVE: 'INACTIVE',
|
||||
COMPLETED: 'COMPLETED',
|
||||
} as const;
|
||||
|
||||
export type ProjectStatusType = (typeof ProjectStatus)[keyof typeof ProjectStatus];
|
||||
|
||||
/**
|
||||
* Schema for creating a new project
|
||||
* - name: required, non-empty string
|
||||
* - description: optional string
|
||||
* - area_name: optional string
|
||||
* - location: optional string
|
||||
* - status: optional, defaults to ACTIVE
|
||||
*/
|
||||
export const createProjectSchema = z.object({
|
||||
name: z
|
||||
.string({ required_error: 'Name is required' })
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(255, 'Name must be at most 255 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.max(1000, 'Description must be at most 1000 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
area_name: z
|
||||
.string()
|
||||
.max(255, 'Area name must be at most 255 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
location: z
|
||||
.string()
|
||||
.max(500, 'Location must be at most 500 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z
|
||||
.enum([ProjectStatus.ACTIVE, ProjectStatus.INACTIVE, ProjectStatus.COMPLETED])
|
||||
.default(ProjectStatus.ACTIVE)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a project
|
||||
* All fields are optional
|
||||
*/
|
||||
export const updateProjectSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(255, 'Name must be at most 255 characters')
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.max(1000, 'Description must be at most 1000 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
area_name: z
|
||||
.string()
|
||||
.max(255, 'Area name must be at most 255 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
location: z
|
||||
.string()
|
||||
.max(500, 'Location must be at most 500 characters')
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z
|
||||
.enum([ProjectStatus.ACTIVE, ProjectStatus.INACTIVE, ProjectStatus.COMPLETED])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace body with validated and typed data
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares for projects
|
||||
*/
|
||||
export const validateCreateProject = validate(createProjectSchema);
|
||||
export const validateUpdateProject = validate(updateProjectSchema);
|
||||
141
water-api/src/validators/role.validator.ts
Normal file
141
water-api/src/validators/role.validator.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Valid role names enum
|
||||
*/
|
||||
export const RoleNameEnum = z.enum(['ADMIN', 'OPERATOR', 'VIEWER']);
|
||||
|
||||
/**
|
||||
* Permissions schema (JSONB object)
|
||||
* Defines what actions a role can perform
|
||||
*/
|
||||
export const permissionsSchema = z
|
||||
.object({
|
||||
// User management
|
||||
users: z
|
||||
.object({
|
||||
create: z.boolean().optional(),
|
||||
read: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Role management
|
||||
roles: z
|
||||
.object({
|
||||
create: z.boolean().optional(),
|
||||
read: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Project management
|
||||
projects: z
|
||||
.object({
|
||||
create: z.boolean().optional(),
|
||||
read: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Meter management
|
||||
meters: z
|
||||
.object({
|
||||
create: z.boolean().optional(),
|
||||
read: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Device management
|
||||
devices: z
|
||||
.object({
|
||||
create: z.boolean().optional(),
|
||||
read: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Readings
|
||||
readings: z
|
||||
.object({
|
||||
create: z.boolean().optional(),
|
||||
read: z.boolean().optional(),
|
||||
export: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(); // Allow additional properties for future extensibility
|
||||
|
||||
/**
|
||||
* Schema for creating a new role
|
||||
* - name: required, must be ADMIN, OPERATOR, or VIEWER
|
||||
* - description: optional string
|
||||
* - permissions: optional JSONB object
|
||||
*/
|
||||
export const createRoleSchema = z.object({
|
||||
name: RoleNameEnum,
|
||||
description: z
|
||||
.string()
|
||||
.max(500, 'Description cannot exceed 500 characters')
|
||||
.nullable()
|
||||
.optional(),
|
||||
permissions: permissionsSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a role
|
||||
* All fields are optional
|
||||
*/
|
||||
export const updateRoleSchema = z.object({
|
||||
name: RoleNameEnum.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.max(500, 'Description cannot exceed 500 characters')
|
||||
.nullable()
|
||||
.optional(),
|
||||
permissions: permissionsSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateRoleInput = z.infer<typeof createRoleSchema>;
|
||||
export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;
|
||||
export type RoleName = z.infer<typeof RoleNameEnum>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace body with validated and typed data
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares
|
||||
*/
|
||||
export const validateCreateRole = validate(createRoleSchema);
|
||||
export const validateUpdateRole = validate(updateRoleSchema);
|
||||
222
water-api/src/validators/tts.validator.ts
Normal file
222
water-api/src/validators/tts.validator.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* TTS Gateway IDs schema
|
||||
*/
|
||||
const gatewayIdsSchema = z.object({
|
||||
gateway_id: z.string(),
|
||||
eui: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS Application IDs schema
|
||||
*/
|
||||
const applicationIdsSchema = z.object({
|
||||
application_id: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS End Device IDs schema
|
||||
* Common structure for identifying devices in TTS payloads
|
||||
*/
|
||||
const endDeviceIdsSchema = z.object({
|
||||
device_id: z.string(),
|
||||
dev_eui: z.string().regex(/^[0-9A-Fa-f]{16}$/, 'dev_eui must be 16 hex characters'),
|
||||
join_eui: z.string().optional(),
|
||||
application_ids: applicationIdsSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS RX Metadata schema
|
||||
* Contains information about the gateway that received the uplink
|
||||
*/
|
||||
const rxMetadataSchema = z.object({
|
||||
gateway_ids: gatewayIdsSchema,
|
||||
time: z.string().optional(),
|
||||
timestamp: z.number().optional(),
|
||||
rssi: z.number().optional(),
|
||||
channel_rssi: z.number().optional(),
|
||||
snr: z.number().optional(),
|
||||
uplink_token: z.string().optional(),
|
||||
channel_index: z.number().optional(),
|
||||
gps_time: z.string().optional(),
|
||||
received_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS Settings schema
|
||||
* Contains LoRaWAN transmission settings
|
||||
*/
|
||||
const settingsSchema = z.object({
|
||||
data_rate: z.object({
|
||||
lora: z.object({
|
||||
bandwidth: z.number().optional(),
|
||||
spreading_factor: z.number().optional(),
|
||||
coding_rate: z.string().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
frequency: z.string().optional(),
|
||||
timestamp: z.number().optional(),
|
||||
time: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS Uplink Message schema
|
||||
* The main uplink message structure containing payload and metadata
|
||||
*/
|
||||
const uplinkMessageSchema = z.object({
|
||||
session_key_id: z.string().optional(),
|
||||
f_port: z.number().min(1).max(255),
|
||||
f_cnt: z.number().optional(),
|
||||
frm_payload: z.string(), // Base64 encoded payload
|
||||
decoded_payload: z.record(z.unknown()).optional(), // Decoded payload from TTS decoder
|
||||
rx_metadata: z.array(rxMetadataSchema).optional(),
|
||||
settings: settingsSchema.optional(),
|
||||
received_at: z.string().optional(),
|
||||
consumed_airtime: z.string().optional(),
|
||||
network_ids: z.object({
|
||||
net_id: z.string().optional(),
|
||||
tenant_id: z.string().optional(),
|
||||
cluster_id: z.string().optional(),
|
||||
cluster_address: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for TTS uplink webhook payload
|
||||
* This is the main payload structure received when a device sends an uplink
|
||||
*/
|
||||
export const uplinkSchema = z.object({
|
||||
end_device_ids: endDeviceIdsSchema,
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
received_at: z.string(),
|
||||
uplink_message: uplinkMessageSchema,
|
||||
simulated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS Join Accept schema
|
||||
* Contains information about the join session
|
||||
*/
|
||||
const joinAcceptSchema = z.object({
|
||||
session_key_id: z.string().optional(),
|
||||
received_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for TTS join webhook payload
|
||||
* Received when a device successfully joins the network
|
||||
*/
|
||||
export const joinSchema = z.object({
|
||||
end_device_ids: endDeviceIdsSchema,
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
received_at: z.string(),
|
||||
join_accept: joinAcceptSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* TTS Downlink schema
|
||||
* Contains information about a queued downlink message
|
||||
*/
|
||||
const downlinkSchema = z.object({
|
||||
f_port: z.number().optional(),
|
||||
f_cnt: z.number().optional(),
|
||||
frm_payload: z.string().optional(),
|
||||
decoded_payload: z.record(z.unknown()).optional(),
|
||||
confirmed: z.boolean().optional(),
|
||||
priority: z.string().optional(),
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for TTS downlink acknowledgment webhook payload
|
||||
* Received when a confirmed downlink is acknowledged by the device
|
||||
*/
|
||||
export const downlinkAckSchema = z.object({
|
||||
end_device_ids: endDeviceIdsSchema,
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
received_at: z.string(),
|
||||
downlink_ack: z.object({
|
||||
session_key_id: z.string().optional(),
|
||||
f_port: z.number().optional(),
|
||||
f_cnt: z.number().optional(),
|
||||
frm_payload: z.string().optional(),
|
||||
decoded_payload: z.record(z.unknown()).optional(),
|
||||
confirmed: z.boolean().optional(),
|
||||
priority: z.string().optional(),
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
downlink_sent: z.object({
|
||||
session_key_id: z.string().optional(),
|
||||
f_port: z.number().optional(),
|
||||
f_cnt: z.number().optional(),
|
||||
frm_payload: z.string().optional(),
|
||||
decoded_payload: z.record(z.unknown()).optional(),
|
||||
confirmed: z.boolean().optional(),
|
||||
priority: z.string().optional(),
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
downlink_failed: z.object({
|
||||
downlink: downlinkSchema.optional(),
|
||||
error: z.object({
|
||||
namespace: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
message_format: z.string().optional(),
|
||||
code: z.number().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
downlink_queued: z.object({
|
||||
session_key_id: z.string().optional(),
|
||||
f_port: z.number().optional(),
|
||||
f_cnt: z.number().optional(),
|
||||
frm_payload: z.string().optional(),
|
||||
decoded_payload: z.record(z.unknown()).optional(),
|
||||
confirmed: z.boolean().optional(),
|
||||
priority: z.string().optional(),
|
||||
correlation_ids: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type TtsUplinkPayload = z.infer<typeof uplinkSchema>;
|
||||
export type TtsJoinPayload = z.infer<typeof joinSchema>;
|
||||
export type TtsDownlinkAckPayload = z.infer<typeof downlinkAckSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory for TTS webhooks
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validateTtsPayload<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid TTS webhook payload',
|
||||
details: errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace body with validated and typed data
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares for TTS webhooks
|
||||
*/
|
||||
export const validateUplink = validateTtsPayload(uplinkSchema);
|
||||
export const validateJoin = validateTtsPayload(joinSchema);
|
||||
export const validateDownlinkAck = validateTtsPayload(downlinkAckSchema);
|
||||
119
water-api/src/validators/user.validator.ts
Normal file
119
water-api/src/validators/user.validator.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Schema for creating a new user
|
||||
* - email: required, must be valid email format
|
||||
* - password: required, minimum 8 characters
|
||||
* - name: required (first_name + last_name combined or separate)
|
||||
* - role_id: required, must be valid UUID
|
||||
* - is_active: optional, defaults to true
|
||||
*/
|
||||
export const createUserSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: 'Email is required' })
|
||||
.email('Invalid email format')
|
||||
.transform((val) => val.toLowerCase().trim()),
|
||||
password: z
|
||||
.string({ required_error: 'Password is required' })
|
||||
.min(8, 'Password must be at least 8 characters'),
|
||||
first_name: z
|
||||
.string({ required_error: 'First name is required' })
|
||||
.min(1, 'First name cannot be empty')
|
||||
.max(100, 'First name cannot exceed 100 characters'),
|
||||
last_name: z
|
||||
.string({ required_error: 'Last name is required' })
|
||||
.min(1, 'Last name cannot be empty')
|
||||
.max(100, 'Last name cannot exceed 100 characters'),
|
||||
role_id: z
|
||||
.number({ required_error: 'Role ID is required' })
|
||||
.int('Role ID must be an integer')
|
||||
.positive('Role ID must be a positive number'),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a user
|
||||
* All fields are optional
|
||||
* Password has different rules (not allowed in regular update)
|
||||
*/
|
||||
export const updateUserSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email('Invalid email format')
|
||||
.transform((val) => val.toLowerCase().trim())
|
||||
.optional(),
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, 'First name cannot be empty')
|
||||
.max(100, 'First name cannot exceed 100 characters')
|
||||
.optional(),
|
||||
last_name: z
|
||||
.string()
|
||||
.min(1, 'Last name cannot be empty')
|
||||
.max(100, 'Last name cannot exceed 100 characters')
|
||||
.optional(),
|
||||
role_id: z
|
||||
.number()
|
||||
.int('Role ID must be an integer')
|
||||
.positive('Role ID must be a positive number')
|
||||
.optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for changing password
|
||||
* - current_password: required
|
||||
* - new_password: required, minimum 8 characters
|
||||
*/
|
||||
export const changePasswordSchema = z.object({
|
||||
current_password: z
|
||||
.string({ required_error: 'Current password is required' })
|
||||
.min(1, 'Current password cannot be empty'),
|
||||
new_password: z
|
||||
.string({ required_error: 'New password is required' })
|
||||
.min(8, 'New password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions derived from schemas
|
||||
*/
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
/**
|
||||
* Generic validation middleware factory
|
||||
* Creates a middleware that validates request body against a Zod schema
|
||||
* @param schema - Zod schema to validate against
|
||||
*/
|
||||
export function validate<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace body with validated and typed data
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured validation middlewares
|
||||
*/
|
||||
export const validateCreateUser = validate(createUserSchema);
|
||||
export const validateUpdateUser = validate(updateUserSchema);
|
||||
export const validateChangePassword = validate(changePasswordSchema);
|
||||
Reference in New Issue
Block a user