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