| function createAlertEngineService(deps) { |
| const { |
| config, |
| alertsRepository, |
| alertRulesRepository, |
| } = deps; |
|
|
| function defaultsForType(type) { |
| const base = { |
| HIGH_TEMPERATURE: { |
| severity: "WARNING", |
| thresholdNumeric: config.alerts.highTemperatureThresholdC, |
| }, |
| GAS_SPIKE: { |
| severity: "WARNING", |
| thresholdNumeric: config.alerts.gasSpikeThresholdRaw, |
| }, |
| SHOCK_DETECTED: { |
| severity: "CRITICAL", |
| thresholdNumeric: null, |
| }, |
| OFFLINE: { |
| severity: "CRITICAL", |
| thresholdNumeric: config.alerts.offlineThresholdMs, |
| }, |
| GPS_LOST: { |
| severity: "WARNING", |
| thresholdNumeric: null, |
| }, |
| }; |
|
|
| return base[type]; |
| } |
|
|
| function resolveThresholdRule(ruleMap, alertType) { |
| const fallback = defaultsForType(alertType); |
| const dbRule = ruleMap.get(alertType); |
|
|
| if (!dbRule) { |
| return { |
| ruleId: null, |
| severity: fallback.severity, |
| thresholdNumeric: fallback.thresholdNumeric, |
| }; |
| } |
|
|
| return { |
| ruleId: dbRule.id, |
| severity: dbRule.severity || fallback.severity, |
| thresholdNumeric: |
| dbRule.threshold_numeric !== null && dbRule.threshold_numeric !== undefined |
| ? Number(dbRule.threshold_numeric) |
| : fallback.thresholdNumeric, |
| }; |
| } |
|
|
| async function applyAlertState(client, payload) { |
| const { |
| tenantId, |
| fleetId, |
| truckId, |
| containerId, |
| tripId, |
| alertType, |
| shouldBeOpen, |
| severity, |
| title, |
| message, |
| latestValueNumeric, |
| latestValueBoolean, |
| thresholdValueNumeric, |
| metadata, |
| alertRuleId, |
| actorUserId, |
| canAutoResolve = true, |
| } = payload; |
|
|
| const existing = await alertsRepository.findActiveAlertForUpdate(client, { |
| tenantId, |
| truckId, |
| containerId, |
| alertType, |
| }); |
|
|
| if (shouldBeOpen) { |
| if (!existing) { |
| const created = await alertsRepository.createAlert(client, { |
| tenantId, |
| fleetId, |
| truckId, |
| containerId, |
| tripId, |
| alertRuleId, |
| alertType, |
| severity, |
| title, |
| message, |
| latestValueNumeric, |
| latestValueBoolean, |
| thresholdValueNumeric, |
| metadata, |
| }); |
|
|
| await alertsRepository.insertAlertEvent(client, { |
| tenantId, |
| alertId: created.id, |
| eventType: "OPENED", |
| fromStatus: null, |
| toStatus: "OPEN", |
| actorUserId, |
| message, |
| metadata, |
| }); |
| return created; |
| } |
|
|
| const updated = await alertsRepository.updateActiveAlert(client, { |
| alertId: existing.id, |
| severity, |
| title, |
| message, |
| latestValueNumeric, |
| latestValueBoolean, |
| thresholdValueNumeric, |
| metadata, |
| }); |
|
|
| return updated; |
| } |
|
|
| if (!existing || !canAutoResolve) { |
| return existing; |
| } |
|
|
| const resolved = await alertsRepository.resolveAlert(client, { |
| alertId: existing.id, |
| message, |
| metadata, |
| }); |
|
|
| await alertsRepository.insertAlertEvent(client, { |
| tenantId, |
| alertId: existing.id, |
| eventType: "RESOLVED", |
| fromStatus: existing.status, |
| toStatus: "RESOLVED", |
| actorUserId, |
| message, |
| metadata, |
| }); |
|
|
| return resolved; |
| } |
|
|
| async function evaluateTelemetryInTransaction(client, context, telemetry) { |
| const rules = await alertRulesRepository.getAlertRuleMap( |
| client, |
| context.tenantId, |
| context.fleetId |
| ); |
|
|
| const highTempRule = resolveThresholdRule(rules, "HIGH_TEMPERATURE"); |
| const gasRule = resolveThresholdRule(rules, "GAS_SPIKE"); |
| const shockRule = resolveThresholdRule(rules, "SHOCK_DETECTED"); |
| const gpsRule = resolveThresholdRule(rules, "GPS_LOST"); |
|
|
| const evaluations = [ |
| { |
| alertType: "HIGH_TEMPERATURE", |
| title: "High temperature detected", |
| shouldBeOpen: |
| telemetry.temperatureC !== null && |
| telemetry.temperatureC > Number(highTempRule.thresholdNumeric), |
| severity: highTempRule.severity, |
| latestValueNumeric: telemetry.temperatureC, |
| latestValueBoolean: null, |
| thresholdValueNumeric: highTempRule.thresholdNumeric, |
| message: |
| telemetry.temperatureC !== null |
| ? `Temperature ${telemetry.temperatureC}C exceeds threshold ${highTempRule.thresholdNumeric}C` |
| : "Temperature threshold exceeded", |
| alertRuleId: highTempRule.ruleId, |
| }, |
| { |
| alertType: "GAS_SPIKE", |
| title: "Gas spike detected", |
| shouldBeOpen: |
| telemetry.gasRaw !== null && telemetry.gasRaw > Number(gasRule.thresholdNumeric), |
| severity: gasRule.severity, |
| latestValueNumeric: telemetry.gasRaw, |
| latestValueBoolean: null, |
| thresholdValueNumeric: gasRule.thresholdNumeric, |
| message: |
| telemetry.gasRaw !== null |
| ? `Gas sensor value ${telemetry.gasRaw} exceeds threshold ${gasRule.thresholdNumeric}` |
| : "Gas threshold exceeded", |
| alertRuleId: gasRule.ruleId, |
| }, |
| { |
| alertType: "SHOCK_DETECTED", |
| title: "Shock detected", |
| shouldBeOpen: telemetry.shock === true, |
| severity: shockRule.severity, |
| latestValueNumeric: null, |
| latestValueBoolean: telemetry.shock, |
| thresholdValueNumeric: shockRule.thresholdNumeric, |
| message: telemetry.shock |
| ? "Shock event reported by sensor" |
| : "Shock condition cleared", |
| alertRuleId: shockRule.ruleId, |
| canAutoResolve: config.alerts.autoResolveShock, |
| }, |
| { |
| alertType: "GPS_LOST", |
| title: "GPS signal lost", |
| shouldBeOpen: telemetry.gpsFix === false, |
| severity: gpsRule.severity, |
| latestValueNumeric: null, |
| latestValueBoolean: telemetry.gpsFix, |
| thresholdValueNumeric: gpsRule.thresholdNumeric, |
| message: |
| telemetry.gpsFix === false |
| ? "GPS fix is unavailable" |
| : "GPS fix restored", |
| alertRuleId: gpsRule.ruleId, |
| }, |
| { |
| alertType: "OFFLINE", |
| title: "Unit offline", |
| shouldBeOpen: false, |
| severity: defaultsForType("OFFLINE").severity, |
| latestValueNumeric: 0, |
| latestValueBoolean: null, |
| thresholdValueNumeric: config.alerts.offlineThresholdMs, |
| message: "Telemetry resumed", |
| alertRuleId: null, |
| }, |
| ]; |
|
|
| for (const evaluation of evaluations) { |
| await applyAlertState(client, { |
| tenantId: context.tenantId, |
| fleetId: context.fleetId, |
| truckId: context.truckId, |
| containerId: context.containerId, |
| tripId: context.tripId, |
| alertType: evaluation.alertType, |
| shouldBeOpen: evaluation.shouldBeOpen, |
| severity: evaluation.severity, |
| title: evaluation.title, |
| message: evaluation.message, |
| latestValueNumeric: evaluation.latestValueNumeric, |
| latestValueBoolean: evaluation.latestValueBoolean, |
| thresholdValueNumeric: evaluation.thresholdValueNumeric, |
| metadata: { |
| telemetrySourceTs: telemetry.sourceTs, |
| receivedAt: telemetry.receivedAt, |
| mqttTopic: telemetry.mqttTopic, |
| }, |
| alertRuleId: evaluation.alertRuleId, |
| actorUserId: null, |
| canAutoResolve: |
| evaluation.canAutoResolve === undefined |
| ? true |
| : evaluation.canAutoResolve, |
| }); |
| } |
| } |
|
|
| async function evaluateOfflineCandidateInTransaction(client, candidate) { |
| const rules = await alertRulesRepository.getAlertRuleMap( |
| client, |
| candidate.tenant_id, |
| candidate.fleet_id |
| ); |
| const offlineRule = resolveThresholdRule(rules, "OFFLINE"); |
|
|
| const staleMs = Math.max( |
| 0, |
| Math.floor(Date.now() - new Date(candidate.received_at).getTime()) |
| ); |
|
|
| await applyAlertState(client, { |
| tenantId: candidate.tenant_id, |
| fleetId: candidate.fleet_id, |
| truckId: candidate.truck_id, |
| containerId: candidate.container_id, |
| tripId: candidate.trip_id, |
| alertType: "OFFLINE", |
| shouldBeOpen: true, |
| severity: offlineRule.severity, |
| title: "Unit offline", |
| message: `No telemetry for ${staleMs} ms`, |
| latestValueNumeric: staleMs, |
| latestValueBoolean: null, |
| thresholdValueNumeric: offlineRule.thresholdNumeric, |
| metadata: { |
| lastReceivedAt: candidate.received_at, |
| mqttTopic: candidate.mqtt_topic, |
| }, |
| alertRuleId: offlineRule.ruleId, |
| actorUserId: null, |
| }); |
| } |
|
|
| return { |
| evaluateTelemetryInTransaction, |
| evaluateOfflineCandidateInTransaction, |
| }; |
| } |
|
|
| module.exports = { |
| createAlertEngineService, |
| }; |
|
|