| const { withTransaction } = require("../db/transaction"); |
| 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; |
|
|
| function normalizeCsvValues(raw, fallback = null) { |
| if (!raw) { |
| return fallback; |
| } |
|
|
| const values = String(raw) |
| .split(",") |
| .map((value) => value.trim().toUpperCase()) |
| .filter(Boolean); |
|
|
| return values.length ? values : 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 parseDateRange(fromRaw, toRaw) { |
| const fromDate = parseDateInput(fromRaw); |
| const toDate = parseDateInput(toRaw); |
|
|
| if (fromRaw && !fromDate) { |
| throw new AppError("Invalid 'from' timestamp", 400); |
| } |
| if (toRaw && !toDate) { |
| throw new AppError("Invalid 'to' timestamp", 400); |
| } |
| if (fromDate && toDate && fromDate > toDate) { |
| throw new AppError("'from' must be earlier than or equal to 'to'", 400); |
| } |
|
|
| return { |
| from: fromDate ? fromDate.toISOString() : null, |
| to: toDate ? toDate.toISOString() : null, |
| }; |
| } |
|
|
| function parseLimit(raw, defaults) { |
| const requested = Number(raw || defaults.fallback); |
| if (!Number.isFinite(requested) || requested <= 0) { |
| throw new AppError("limit must be a positive number", 400); |
| } |
|
|
| return Math.min(Math.floor(requested), defaults.max); |
| } |
|
|
| function createAlertsService(deps) { |
| const { alertsRepository, config } = deps; |
| const maxHistoryLimit = config?.query?.historyMaxLimit || 2000; |
| const defaultHistoryLimit = config?.query?.historyDefaultLimit || 240; |
|
|
| async function listAlerts(query) { |
| const statuses = query.status |
| ? normalizeCsvValues(query.status) |
| : ["OPEN", "ACKNOWLEDGED"]; |
|
|
| const rows = await alertsRepository.listAlerts(deps.pool, { |
| tenantCode: query.tenantCode || null, |
| statuses, |
| severities: normalizeCsvValues(query.severity), |
| limit: parseLimit(query.limit, { |
| fallback: defaultHistoryLimit, |
| max: maxHistoryLimit, |
| }), |
| }); |
|
|
| const items = rows.map((row) => ({ |
| key: `${row.truck_code}::${row.container_code}`, |
| truckId: row.truck_code, |
| containerId: row.container_code, |
| tenantId: row.tenant_code, |
| receivedAt: row.last_event_at, |
| alert: { |
| code: row.alert_type, |
| severity: row.severity, |
| message: row.message, |
| value: |
| row.latest_value_numeric !== null |
| ? row.latest_value_numeric |
| : row.latest_value_boolean, |
| }, |
| id: row.id, |
| fleetId: row.fleet_code, |
| alertType: row.alert_type, |
| severity: row.severity, |
| status: row.status, |
| title: row.title, |
| message: row.message, |
| openedAt: row.opened_at, |
| acknowledgedAt: row.acknowledged_at, |
| resolvedAt: row.resolved_at, |
| lastEventAt: row.last_event_at, |
| latestValueNumeric: row.latest_value_numeric, |
| latestValueBoolean: row.latest_value_boolean, |
| thresholdValueNumeric: row.threshold_value_numeric, |
| metadata: row.metadata_json, |
| })); |
|
|
| return { |
| count: items.length, |
| items, |
| }; |
| } |
|
|
| async function listAlertHistory(query) { |
| const statuses = normalizeCsvValues(query.status); |
| const severities = normalizeCsvValues(query.severity); |
| const { from, to } = parseDateRange(query.from, query.to); |
| const tenantId = parseTenantId(query.tenantId); |
| const rows = await alertsRepository.listAlertHistory(deps.pool, { |
| tenantCode: query.tenantCode || null, |
| tenantId, |
| statuses, |
| severities, |
| truckCode: query.truckId ? String(query.truckId).trim() : null, |
| containerCode: query.containerId ? String(query.containerId).trim() : null, |
| from, |
| to, |
| limit: parseLimit(query.limit, { |
| fallback: defaultHistoryLimit, |
| max: maxHistoryLimit, |
| }), |
| }); |
|
|
| const items = rows.map((row) => ({ |
| key: `${row.truck_code}::${row.container_code}`, |
| id: row.id, |
| tenantId: row.tenant_id, |
| tenantCode: row.tenant_code, |
| fleetId: row.fleet_code, |
| truckId: row.truck_code, |
| containerId: row.container_code, |
| alertType: row.alert_type, |
| severity: row.severity, |
| status: row.status, |
| title: row.title, |
| message: row.message, |
| openedAt: row.opened_at, |
| acknowledgedAt: row.acknowledged_at, |
| resolvedAt: row.resolved_at, |
| lastEventAt: row.last_event_at, |
| latestValueNumeric: row.latest_value_numeric, |
| latestValueBoolean: row.latest_value_boolean, |
| thresholdValueNumeric: row.threshold_value_numeric, |
| metadata: row.metadata_json, |
| })); |
|
|
| return { |
| count: items.length, |
| filters: { |
| status: statuses, |
| severity: severities, |
| tenantId, |
| truckId: query.truckId || null, |
| containerId: query.containerId || null, |
| from, |
| to, |
| }, |
| items, |
| }; |
| } |
|
|
| async function getAlertEvents(alertId, query, tenantCode) { |
| const tenantId = parseTenantId(query.tenantId); |
| const limit = parseLimit(query.limit, { |
| fallback: 300, |
| max: maxHistoryLimit, |
| }); |
|
|
| const rows = await alertsRepository.getAlertEvents( |
| deps.pool, |
| { |
| alertId, |
| limit, |
| tenantCode, |
| tenantId, |
| } |
| ); |
|
|
| return { |
| count: rows.length, |
| alertId, |
| items: rows.map((row) => ({ |
| id: row.id, |
| alertId: row.alert_id, |
| tenantCode: row.tenant_code, |
| eventType: row.event_type, |
| fromStatus: row.from_status, |
| toStatus: row.to_status, |
| actorUserId: row.actor_user_id, |
| actorEmail: row.actor_email, |
| actorName: row.actor_name, |
| eventAt: row.event_at, |
| message: row.message, |
| metadata: row.metadata_json, |
| })), |
| }; |
| } |
|
|
| async function transitionAlert(alertId, action, options = {}) { |
| const normalizedAction = String(action || "").trim().toUpperCase(); |
| if (!["ACKNOWLEDGE", "RESOLVE"].includes(normalizedAction)) { |
| throw new AppError("action must be ACKNOWLEDGE or RESOLVE", 400); |
| } |
|
|
| return withTransaction(deps.pool, async (client) => { |
| const current = await alertsRepository.findAlertByIdForUpdate(client, alertId); |
| if (!current) { |
| throw new AppError("Alert not found", 404); |
| } |
|
|
| if (current.status === "RESOLVED" && normalizedAction === "ACKNOWLEDGE") { |
| throw new AppError("Resolved alerts cannot be acknowledged", 409); |
| } |
|
|
| if (current.status === "RESOLVED" && normalizedAction === "RESOLVE") { |
| return current; |
| } |
|
|
| const metadata = { |
| source: "manual_transition", |
| requestedAction: normalizedAction, |
| }; |
|
|
| let updated; |
| if (normalizedAction === "ACKNOWLEDGE") { |
| updated = await alertsRepository.acknowledgeAlert(client, { |
| alertId, |
| message: options.message, |
| metadata, |
| }); |
|
|
| await alertsRepository.insertAlertEvent(client, { |
| tenantId: current.tenant_id, |
| alertId, |
| eventType: "ACKNOWLEDGED", |
| fromStatus: current.status, |
| toStatus: "ACKNOWLEDGED", |
| actorUserId: options.actorUserId || null, |
| message: options.message || "Alert acknowledged", |
| metadata, |
| }); |
| } else { |
| updated = await alertsRepository.resolveAlert(client, { |
| alertId, |
| message: options.message, |
| metadata, |
| }); |
|
|
| await alertsRepository.insertAlertEvent(client, { |
| tenantId: current.tenant_id, |
| alertId, |
| eventType: "RESOLVED", |
| fromStatus: current.status, |
| toStatus: "RESOLVED", |
| actorUserId: options.actorUserId || null, |
| message: options.message || "Alert resolved", |
| metadata, |
| }); |
| } |
|
|
| return updated; |
| }); |
| } |
|
|
| return { |
| listAlerts, |
| listAlertHistory, |
| getAlertEvents, |
| transitionAlert, |
| }; |
| } |
|
|
| module.exports = { |
| createAlertsService, |
| }; |
|
|