Mobile friendly, lint correction, typescript error clear
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { sendSms } from "@/lib/sms";
|
||||
import { AlertPolicySchema, DEFAULT_POLICY, normalizeAlertPolicy } from "@/lib/alerts/policy";
|
||||
import { AlertPolicySchema, DEFAULT_POLICY } from "@/lib/alerts/policy";
|
||||
|
||||
type Recipient = {
|
||||
userId?: string;
|
||||
@@ -13,12 +13,43 @@ type Recipient = {
|
||||
};
|
||||
|
||||
function normalizeEventType(value: unknown) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
if (!raw) return raw;
|
||||
const cleaned = raw.replace(/[_\s]+/g, "-").replace(/-+/g, "-");
|
||||
if (cleaned === "micro-stop") return "microstop";
|
||||
if (cleaned === "macro-stop") return "macrostop";
|
||||
if (cleaned === "slowcycle") return "slow-cycle";
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: any): number | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const data = raw.data ?? raw;
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function unwrapEventData(raw: unknown) {
|
||||
const payload = asRecord(raw);
|
||||
const inner = asRecord(payload?.data) ?? payload;
|
||||
return { payload, inner };
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function readBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown): number | null {
|
||||
const payload = asRecord(raw);
|
||||
if (!payload) return null;
|
||||
const data = asRecord(payload.data) ?? payload;
|
||||
const candidates = [
|
||||
data?.duration_seconds,
|
||||
data?.duration_sec,
|
||||
@@ -67,6 +98,7 @@ async function ensurePolicy(orgId: string) {
|
||||
|
||||
async function loadRecipients(orgId: string, role: string, eventType: string): Promise<Recipient[]> {
|
||||
const roleUpper = role.toUpperCase();
|
||||
const normalizedEventType = normalizeEventType(eventType);
|
||||
const [members, external] = await Promise.all([
|
||||
prisma.orgUser.findMany({
|
||||
where: { orgId, role: roleUpper },
|
||||
@@ -105,7 +137,7 @@ async function loadRecipients(orgId: string, role: string, eventType: string): P
|
||||
.filter((c) => {
|
||||
const types = Array.isArray(c.eventTypes) ? c.eventTypes : null;
|
||||
if (!types || !types.length) return true;
|
||||
return types.includes(eventType);
|
||||
return types.some((type) => normalizeEventType(type) === normalizedEventType);
|
||||
})
|
||||
.map((c) => ({
|
||||
contactId: c.id,
|
||||
@@ -143,7 +175,7 @@ function buildAlertMessage(params: {
|
||||
}
|
||||
|
||||
async function shouldSendNotification(params: {
|
||||
eventId: string;
|
||||
eventIds: string[];
|
||||
ruleId: string;
|
||||
role: string;
|
||||
channel: string;
|
||||
@@ -153,7 +185,7 @@ async function shouldSendNotification(params: {
|
||||
}) {
|
||||
const existing = await prisma.alertNotification.findFirst({
|
||||
where: {
|
||||
eventId: params.eventId,
|
||||
eventId: { in: params.eventIds },
|
||||
ruleId: params.ruleId,
|
||||
role: params.role,
|
||||
channel: params.channel,
|
||||
@@ -171,6 +203,22 @@ async function shouldSendNotification(params: {
|
||||
return elapsed >= repeatMin * 60 * 1000;
|
||||
}
|
||||
|
||||
async function resolveAlertEventIds(orgId: string, alertId: string, fallbackId: string) {
|
||||
const events = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
data: {
|
||||
path: ["alert_id"],
|
||||
equals: alertId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = events.map((row) => row.id);
|
||||
if (!ids.includes(fallbackId)) ids.push(fallbackId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function recordNotification(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
@@ -250,6 +298,18 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
const rule = policy.rules.find((r) => normalizeEventType(r.eventType) === eventType);
|
||||
if (!rule) return;
|
||||
|
||||
const { payload, inner } = unwrapEventData(event.data);
|
||||
const alertId = readString(payload?.alert_id ?? inner?.alert_id);
|
||||
const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
const lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
|
||||
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
|
||||
if (isAutoAck) return;
|
||||
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return;
|
||||
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const durationSec = extractDurationSec(event.data);
|
||||
const durationMin = durationSec != null ? durationSec / 60 : 0;
|
||||
const machine = await prisma.machine.findUnique({
|
||||
@@ -257,6 +317,9 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
select: { name: true, code: true },
|
||||
});
|
||||
const delivered = new Set<string>();
|
||||
const notificationEventIds = alertId
|
||||
? await resolveAlertEventIds(event.orgId, alertId, event.id)
|
||||
: [event.id];
|
||||
|
||||
for (const [roleName, roleRule] of Object.entries(rule.roles)) {
|
||||
if (!roleRule?.enabled) continue;
|
||||
@@ -283,7 +346,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
if (delivered.has(key)) continue;
|
||||
|
||||
const allowed = await shouldSendNotification({
|
||||
eventId: event.id,
|
||||
eventIds: notificationEventIds,
|
||||
ruleId: rule.id,
|
||||
role: roleName,
|
||||
channel,
|
||||
@@ -321,8 +384,8 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
status: "sent",
|
||||
});
|
||||
delivered.add(key);
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ? String(err.message) : "notification_failed";
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "notification_failed";
|
||||
await recordNotification({
|
||||
orgId: event.orgId,
|
||||
machineId: event.machineId,
|
||||
|
||||
261
lib/alerts/getAlertsInboxData.ts
Normal file
261
lib/alerts/getAlertsInboxData.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
type AlertsInboxParams = {
|
||||
orgId: string;
|
||||
range?: string;
|
||||
start?: Date | null;
|
||||
end?: Date | null;
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
eventType?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
shift?: string;
|
||||
includeUpdates?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function pickRange(range: string, start?: Date | null, end?: Date | null) {
|
||||
const now = new Date();
|
||||
if (range === "custom") {
|
||||
const startFallback = new Date(now.getTime() - RANGE_MS["24h"]);
|
||||
return {
|
||||
range,
|
||||
start: start ?? startFallback,
|
||||
end: end ?? now,
|
||||
};
|
||||
}
|
||||
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||
return { range, start: new Date(now.getTime() - ms), end: now };
|
||||
}
|
||||
|
||||
function safeString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function safeNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function parsePayload(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
const payload =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const innerCandidate = payload.data;
|
||||
const inner =
|
||||
innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate)
|
||||
? (innerCandidate as Record<string, unknown>)
|
||||
: payload;
|
||||
return { payload, inner };
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown) {
|
||||
const { payload, inner } = parsePayload(raw);
|
||||
const candidates = [
|
||||
inner?.duration_seconds,
|
||||
inner?.duration_sec,
|
||||
inner?.stoppage_duration_seconds,
|
||||
inner?.stop_duration_seconds,
|
||||
payload?.duration_seconds,
|
||||
payload?.duration_sec,
|
||||
payload?.stoppage_duration_seconds,
|
||||
payload?.stop_duration_seconds,
|
||||
];
|
||||
for (const val of candidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
|
||||
}
|
||||
const msCandidates = [inner?.duration_ms, inner?.durationMs, payload?.duration_ms, payload?.durationMs];
|
||||
for (const val of msCandidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
|
||||
return Math.round(val / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const startMs = inner.start_ts ?? inner.startTs ?? payload.start_ts ?? payload.startTs ?? null;
|
||||
const endMs = inner.end_ts ?? inner.endTs ?? payload.end_ts ?? payload.endTs ?? null;
|
||||
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
|
||||
return Math.round((endMs - startMs) / 1000);
|
||||
}
|
||||
|
||||
const actual = safeNumber(inner.actual_cycle_time ?? payload.actual_cycle_time);
|
||||
const theoretical = safeNumber(inner.theoretical_cycle_time ?? payload.theoretical_cycle_time);
|
||||
if (actual != null && theoretical != null) {
|
||||
return Math.max(0, actual - theoretical);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimeMinutes(value?: string | null) {
|
||||
if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
|
||||
const [hh, mm] = value.split(":").map((n) => Number(n));
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
|
||||
return hh * 60 + mm;
|
||||
}
|
||||
|
||||
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(ts);
|
||||
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||
return hours * 60 + minutes;
|
||||
} catch {
|
||||
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveShift(
|
||||
shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
|
||||
ts: Date,
|
||||
timeZone: string
|
||||
) {
|
||||
if (!shifts.length) return null;
|
||||
const nowMin = getLocalMinutes(ts, timeZone);
|
||||
for (const shift of shifts) {
|
||||
if (shift.enabled === false) continue;
|
||||
const start = parseTimeMinutes(shift.startTime);
|
||||
const end = parseTimeMinutes(shift.endTime);
|
||||
if (start == null || end == null) continue;
|
||||
if (start <= end) {
|
||||
if (nowMin >= start && nowMin < end) return shift.name;
|
||||
} else {
|
||||
if (nowMin >= start || nowMin < end) return shift.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const {
|
||||
orgId,
|
||||
range = "24h",
|
||||
start,
|
||||
end,
|
||||
machineId,
|
||||
location,
|
||||
eventType,
|
||||
severity,
|
||||
status,
|
||||
shift,
|
||||
includeUpdates = false,
|
||||
limit = 200,
|
||||
} = params;
|
||||
|
||||
const picked = pickRange(range, start, end);
|
||||
const normalizedStatus = safeString(status)?.toLowerCase();
|
||||
const normalizedShift = safeString(shift);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200;
|
||||
|
||||
const where = {
|
||||
orgId,
|
||||
ts: { gte: picked.start, lte: picked.end },
|
||||
...(machineId ? { machineId } : {}),
|
||||
...(eventType ? { eventType } : {}),
|
||||
...(severity ? { severity } : {}),
|
||||
...(location ? { machine: { location } } : {}),
|
||||
};
|
||||
|
||||
const [events, shifts, settings] = await Promise.all([
|
||||
prisma.machineEvent.findMany({
|
||||
where,
|
||||
orderBy: { ts: "desc" },
|
||||
take: safeLimit,
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
data: true,
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
machine: {
|
||||
select: {
|
||||
name: true,
|
||||
location: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.orgShift.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { name: true, startTime: true, endTime: true, enabled: true },
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { timezone: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const mapped = [];
|
||||
|
||||
for (const ev of events) {
|
||||
const { payload, inner } = parsePayload(ev.data);
|
||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
|
||||
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
|
||||
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||
|
||||
mapped.push({
|
||||
id: ev.id,
|
||||
ts: ev.ts,
|
||||
eventType: ev.eventType,
|
||||
severity: ev.severity,
|
||||
title: ev.title,
|
||||
description: ev.description,
|
||||
machineId: ev.machineId,
|
||||
machineName: ev.machine?.name ?? null,
|
||||
location: ev.machine?.location ?? null,
|
||||
workOrderId: ev.workOrderId ?? null,
|
||||
sku: ev.sku ?? null,
|
||||
durationSec: extractDurationSec(ev.data),
|
||||
status: statusLabel,
|
||||
shift: shiftName,
|
||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||
isUpdate,
|
||||
isAutoAck,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||
events: mapped,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user