cargo / src /services /alertsService.js
vish85521's picture
Upload 53 files
964569f verified
Raw
History Blame Contribute Delete
8.28 kB
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,
};