const { AppError } = require("../utils/appError"); const { parseDateInput } = require("../utils/time"); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const ALERT_STATUSES = new Set(["OPEN", "ACKNOWLEDGED", "RESOLVED"]); const ALERT_SEVERITIES = new Set(["INFO", "WARNING", "CRITICAL"]); function toNumber(value, fallback = 0) { if (value === null || value === undefined || value === "") { return fallback; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function parseTenantId(raw) { if (raw === undefined || raw === null || raw === "") { return null; } const value = String(raw).trim(); if (!UUID_PATTERN.test(value)) { throw new AppError("tenantId must be a valid UUID", 400); } return value; } function parseWindow(query, defaults = {}) { const defaultHours = defaults.defaultHours || 24; const now = new Date(); const fallbackFrom = new Date(now.getTime() - defaultHours * 60 * 60 * 1000); const fromDate = query.from ? parseDateInput(query.from) : fallbackFrom; const toDate = query.to ? parseDateInput(query.to) : now; if (query.from && !fromDate) { throw new AppError("Invalid 'from' timestamp", 400); } if (query.to && !toDate) { throw new AppError("Invalid 'to' timestamp", 400); } if (fromDate > toDate) { throw new AppError("'from' must be earlier than or equal to 'to'", 400); } return { from: fromDate.toISOString(), to: toDate.toISOString(), }; } function parsePositiveInteger(raw, options) { const fallback = options.fallback; const max = options.max; const parsed = raw === undefined || raw === null || raw === "" ? fallback : Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { throw new AppError(`${options.label} must be a positive number`, 400); } return Math.min(Math.floor(parsed), max); } function parseEnumCsv(raw, allowedSet, label) { if (!raw) { return null; } const values = String(raw) .split(",") .map((value) => value.trim().toUpperCase()) .filter(Boolean); if (!values.length) { return null; } const invalid = values.filter((value) => !allowedSet.has(value)); if (invalid.length > 0) { throw new AppError(`${label} contains invalid values: ${invalid.join(", ")}`, 400); } return values; } function createReportsService(deps) { const { reportsRepository, config } = deps; async function getFleetSummaryReport(query) { const window = parseWindow(query, { defaultHours: 24 }); const tenantId = parseTenantId(query.tenantId); const bucketMinutes = parsePositiveInteger(query.bucketMinutes, { label: "bucketMinutes", fallback: 60, max: 1440, }); const payload = await reportsRepository.getFleetSummaryReport(deps.pool, { tenantCode: query.tenantCode || null, tenantId, from: window.from, to: window.to, bucketInterval: `${bucketMinutes} minutes`, offlineThresholdMs: config.alerts.offlineThresholdMs, }); const summary = payload.summary || {}; return { window: { from: window.from, to: window.to, bucketMinutes, }, overview: { telemetryPoints: toNumber(summary.telemetry_points), activeUnitsInWindow: toNumber(summary.active_units_in_window), onlineUnitsCurrent: toNumber(summary.online_units_current), offlineUnitsCurrent: toNumber(summary.offline_units_current), gasAlertSamples: toNumber(summary.gas_alert_samples), shockSamples: toNumber(summary.shock_samples), firstPointAt: summary.first_point_at || null, lastPointAt: summary.last_point_at || null, latestTelemetryAt: summary.latest_received_at || null, }, metrics: { avgTemperatureC: toNumber(summary.avg_temperature_c, null), maxTemperatureC: toNumber(summary.max_temperature_c, null), avgHumidityPct: toNumber(summary.avg_humidity_pct, null), avgSpeedKph: toNumber(summary.avg_speed_kph, null), }, trend: payload.trend.map((row) => ({ bucketAt: row.bucket_at, sampleCount: toNumber(row.sample_count), avgTemperatureC: toNumber(row.avg_temperature_c, null), maxTemperatureC: toNumber(row.max_temperature_c, null), avgHumidityPct: toNumber(row.avg_humidity_pct, null), avgSpeedKph: toNumber(row.avg_speed_kph, null), })), }; } async function getAlertSummaryReport(query) { const window = parseWindow(query, { defaultHours: 24 }); const tenantId = parseTenantId(query.tenantId); const bucketMinutes = parsePositiveInteger(query.bucketMinutes, { label: "bucketMinutes", fallback: 60, max: 1440, }); const statuses = parseEnumCsv(query.status, ALERT_STATUSES, "status"); const severities = parseEnumCsv(query.severity, ALERT_SEVERITIES, "severity"); const payload = await reportsRepository.getAlertSummaryReport(deps.pool, { tenantCode: query.tenantCode || null, tenantId, truckCode: query.truckId ? String(query.truckId).trim() : null, containerCode: query.containerId ? String(query.containerId).trim() : null, from: window.from, to: window.to, statuses, severities, bucketInterval: `${bucketMinutes} minutes`, }); const summary = payload.summary || {}; return { window: { from: window.from, to: window.to, bucketMinutes, }, filters: { status: statuses, severity: severities, tenantId, truckId: query.truckId || null, containerId: query.containerId || null, }, overview: { totalAlerts: toNumber(summary.total_alerts), openAlerts: toNumber(summary.open_alerts), acknowledgedAlerts: toNumber(summary.acknowledged_alerts), resolvedAlerts: toNumber(summary.resolved_alerts), criticalAlerts: toNumber(summary.critical_alerts), warningAlerts: toNumber(summary.warning_alerts), infoAlerts: toNumber(summary.info_alerts), impactedUnits: toNumber(summary.impacted_units), lastAlertAt: summary.last_alert_at || null, }, bySeverity: payload.bySeverity.map((row) => ({ severity: row.severity, count: toNumber(row.count), })), byStatus: payload.byStatus.map((row) => ({ status: row.status, count: toNumber(row.count), })), byAlertType: payload.byType.map((row) => ({ alertType: row.alert_type, count: toNumber(row.count), })), timeline: payload.timeline.map((row) => ({ bucketAt: row.bucket_at, count: toNumber(row.count), criticalCount: toNumber(row.critical_count), warningCount: toNumber(row.warning_count), infoCount: toNumber(row.info_count), })), topContainers: payload.topContainers.map((row) => ({ truckId: row.truck_code, containerId: row.container_code, alertCount: toNumber(row.alert_count), lastAlertAt: row.last_alert_at, })), }; } async function getDeviceHealthSummaryReport(query) { const tenantId = parseTenantId(query.tenantId); const offlineMinutes = parsePositiveInteger(query.offlineMinutes, { label: "offlineMinutes", fallback: 15, max: 1440, }); const limit = parsePositiveInteger(query.limit, { label: "limit", fallback: 50, max: 200, }); const payload = await reportsRepository.getDeviceHealthSummaryReport(deps.pool, { tenantCode: query.tenantCode || null, tenantId, offlineThresholdMs: offlineMinutes * 60 * 1000, limit, }); const summary = payload.summary || {}; return { generatedAt: new Date().toISOString(), offlineThresholdMinutes: offlineMinutes, overview: { trackedUnits: toNumber(summary.tracked_units), onlineUnits: toNumber(summary.online_units), offlineUnits: toNumber(summary.offline_units), activeDevices: toNumber(summary.active_devices), staleDevices: toNumber(summary.stale_devices), latestTelemetryAt: summary.latest_telemetry_at || null, }, devicesByType: { sensorNodes: toNumber(summary.active_sensor_devices), gatewayNodes: toNumber(summary.active_gateway_devices), }, offlineUnits: payload.offlineUnits.map((row) => ({ tenantCode: row.tenant_code, truckId: row.truck_code, containerId: row.container_code, lastTelemetryAt: row.received_at, minutesSinceLastTelemetry: toNumber(row.minutes_since_last_telemetry, null), })), }; } return { getFleetSummaryReport, getAlertSummaryReport, getDeviceHealthSummaryReport, }; } module.exports = { createReportsService, };