Alert system

This commit is contained in:
Marcelo
2026-01-15 21:03:41 +00:00
parent 9f1af71d15
commit 0f88207f3f
20 changed files with 1791 additions and 145 deletions

351
lib/alerts/engine.ts Normal file
View File

@@ -0,0 +1,351 @@
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
import { sendSms } from "@/lib/sms";
import { AlertPolicySchema, DEFAULT_POLICY, normalizeAlertPolicy } from "@/lib/alerts/policy";
type Recipient = {
userId?: string;
contactId?: string;
name?: string | null;
email?: string | null;
phone?: string | null;
role: string;
};
function normalizeEventType(value: unknown) {
return String(value ?? "").trim().toLowerCase();
}
function extractDurationSec(raw: any): number | null {
if (!raw || typeof raw !== "object") return null;
const data = raw.data ?? raw;
const candidates = [
data?.duration_seconds,
data?.duration_sec,
data?.stoppage_duration_seconds,
data?.stop_duration_seconds,
];
for (const val of candidates) {
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
}
const msCandidates = [data?.duration_ms, data?.durationMs];
for (const val of msCandidates) {
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
return Math.round(val / 1000);
}
}
const startMs = data?.start_ts ?? data?.startTs ?? null;
const endMs = data?.end_ts ?? data?.endTs ?? null;
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
return Math.round((endMs - startMs) / 1000);
}
return null;
}
async function ensurePolicy(orgId: string) {
const existing = await prisma.alertPolicy.findUnique({
where: { orgId },
select: { id: true, policyJson: true },
});
if (existing) {
const parsed = AlertPolicySchema.safeParse(existing.policyJson);
return parsed.success ? parsed.data : DEFAULT_POLICY;
}
await prisma.alertPolicy.create({
data: {
orgId,
policyJson: DEFAULT_POLICY,
},
});
return DEFAULT_POLICY;
}
async function loadRecipients(orgId: string, role: string, eventType: string): Promise<Recipient[]> {
const roleUpper = role.toUpperCase();
const [members, external] = await Promise.all([
prisma.orgUser.findMany({
where: { orgId, role: roleUpper },
select: {
userId: true,
user: { select: { name: true, email: true, phone: true, isActive: true } },
},
}),
prisma.alertContact.findMany({
where: {
orgId,
isActive: true,
OR: [{ roleScope: roleUpper }, { roleScope: "CUSTOM" }],
},
select: {
id: true,
name: true,
email: true,
phone: true,
eventTypes: true,
},
}),
]);
const memberRecipients = members
.filter((m) => m.user?.isActive !== false)
.map((m) => ({
userId: m.userId,
name: m.user?.name ?? null,
email: m.user?.email ?? null,
phone: m.user?.phone ?? null,
role: roleUpper,
}));
const externalRecipients = external
.filter((c) => {
const types = Array.isArray(c.eventTypes) ? c.eventTypes : null;
if (!types || !types.length) return true;
return types.includes(eventType);
})
.map((c) => ({
contactId: c.id,
name: c.name ?? null,
email: c.email ?? null,
phone: c.phone ?? null,
role: roleUpper,
}));
return [...memberRecipients, ...externalRecipients];
}
function buildAlertMessage(params: {
machineName: string;
machineCode?: string | null;
eventType: string;
title: string;
description?: string | null;
durationMin?: number | null;
}) {
const durationLabel =
params.durationMin != null ? `${Math.round(params.durationMin)} min` : "n/a";
const subject = `[MIS] ${params.eventType} - ${params.machineName}`;
const text = [
`Machine: ${params.machineName}${params.machineCode ? ` (${params.machineCode})` : ""}`,
`Event: ${params.eventType}`,
`Title: ${params.title}`,
params.description ? `Description: ${params.description}` : null,
`Duration: ${durationLabel}`,
]
.filter(Boolean)
.join("\n");
const html = text.replace(/\n/g, "<br/>");
return { subject, text, html };
}
async function shouldSendNotification(params: {
eventId: string;
ruleId: string;
role: string;
channel: string;
contactId?: string;
userId?: string;
repeatMinutes?: number;
}) {
const existing = await prisma.alertNotification.findFirst({
where: {
eventId: params.eventId,
ruleId: params.ruleId,
role: params.role,
channel: params.channel,
...(params.contactId ? { contactId: params.contactId } : {}),
...(params.userId ? { userId: params.userId } : {}),
},
orderBy: { sentAt: "desc" },
select: { sentAt: true },
});
if (!existing) return true;
const repeatMin = Number(params.repeatMinutes ?? 0);
if (!repeatMin || repeatMin <= 0) return false;
const elapsed = Date.now() - new Date(existing.sentAt).getTime();
return elapsed >= repeatMin * 60 * 1000;
}
async function recordNotification(params: {
orgId: string;
machineId: string;
eventId: string;
eventType: string;
ruleId: string;
role: string;
channel: string;
contactId?: string;
userId?: string;
status: string;
error?: string | null;
}) {
await prisma.alertNotification.create({
data: {
orgId: params.orgId,
machineId: params.machineId,
eventId: params.eventId,
eventType: params.eventType,
ruleId: params.ruleId,
role: params.role,
channel: params.channel,
contactId: params.contactId ?? null,
userId: params.userId ?? null,
status: params.status,
error: params.error ?? null,
},
});
}
async function emitFailureEvent(params: {
orgId: string;
machineId: string;
eventType: string;
role: string;
channel: string;
error: string;
}) {
await prisma.machineEvent.create({
data: {
orgId: params.orgId,
machineId: params.machineId,
ts: new Date(),
topic: "alert-delivery-failed",
eventType: "alert-delivery-failed",
severity: "critical",
requiresAck: true,
title: "Alert delivery failed",
description: params.error,
data: {
sourceEventType: params.eventType,
role: params.role,
channel: params.channel,
error: params.error,
},
},
});
}
export async function evaluateAlertsForEvent(eventId: string) {
const event = await prisma.machineEvent.findUnique({
where: { id: eventId },
select: {
id: true,
orgId: true,
machineId: true,
eventType: true,
title: true,
description: true,
data: true,
},
});
if (!event) return;
const policy = await ensurePolicy(event.orgId);
const eventType = normalizeEventType(event.eventType);
const rule = policy.rules.find((r) => normalizeEventType(r.eventType) === eventType);
if (!rule) return;
const durationSec = extractDurationSec(event.data);
const durationMin = durationSec != null ? durationSec / 60 : 0;
const machine = await prisma.machine.findUnique({
where: { id: event.machineId },
select: { name: true, code: true },
});
const delivered = new Set<string>();
for (const [roleName, roleRule] of Object.entries(rule.roles)) {
if (!roleRule?.enabled) continue;
if (durationMin < Number(roleRule.afterMinutes ?? 0)) continue;
const recipients = await loadRecipients(event.orgId, roleName, eventType);
if (!recipients.length) continue;
const message = buildAlertMessage({
machineName: machine?.name ?? "Unknown Machine",
machineCode: machine?.code ?? null,
eventType,
title: event.title ?? "Alert",
description: event.description ?? null,
durationMin,
});
for (const recipient of recipients) {
for (const channel of roleRule.channels ?? []) {
const canSend =
channel === "email" ? !!recipient.email : channel === "sms" ? !!recipient.phone : false;
if (!canSend) continue;
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
if (delivered.has(key)) continue;
const allowed = await shouldSendNotification({
eventId: event.id,
ruleId: rule.id,
role: roleName,
channel,
contactId: recipient.contactId,
userId: recipient.userId,
repeatMinutes: rule.repeatMinutes,
});
if (!allowed) continue;
try {
if (channel === "email") {
await sendEmail({
to: recipient.email as string,
subject: message.subject,
text: message.text,
html: message.html,
});
} else if (channel === "sms") {
await sendSms({
to: recipient.phone as string,
body: message.text,
});
}
await recordNotification({
orgId: event.orgId,
machineId: event.machineId,
eventId: event.id,
eventType,
ruleId: rule.id,
role: roleName,
channel,
contactId: recipient.contactId,
userId: recipient.userId,
status: "sent",
});
delivered.add(key);
} catch (err: any) {
const msg = err?.message ? String(err.message) : "notification_failed";
await recordNotification({
orgId: event.orgId,
machineId: event.machineId,
eventId: event.id,
eventType,
ruleId: rule.id,
role: roleName,
channel,
contactId: recipient.contactId,
userId: recipient.userId,
status: "failed",
error: msg,
});
await emitFailureEvent({
orgId: event.orgId,
machineId: event.machineId,
eventType,
role: roleName,
channel,
error: msg,
});
}
}
}
}
}

59
lib/alerts/policy.ts Normal file
View File

@@ -0,0 +1,59 @@
import { z } from "zod";
const ROLE_NAMES = ["MEMBER", "ADMIN", "OWNER"] as const;
const CHANNELS = ["email", "sms"] as const;
const EVENT_TYPES = ["macrostop", "microstop", "slow-cycle", "offline", "error"] as const;
const RoleRule = z.object({
enabled: z.boolean(),
afterMinutes: z.number().int().min(0),
channels: z.array(z.enum(CHANNELS)).default(["email"]),
});
const Rule = z.object({
id: z.string(),
eventType: z.enum(EVENT_TYPES),
roles: z.record(z.enum(ROLE_NAMES), RoleRule),
repeatMinutes: z.number().int().min(0).optional(),
});
export const AlertPolicySchema = z.object({
version: z.number().int().min(1).default(1),
defaults: z.record(z.enum(ROLE_NAMES), RoleRule),
rules: z.array(Rule),
});
export type AlertPolicy = z.infer<typeof AlertPolicySchema>;
export const DEFAULT_POLICY: AlertPolicy = {
version: 1,
defaults: {
MEMBER: { enabled: true, afterMinutes: 0, channels: ["email"] },
ADMIN: { enabled: true, afterMinutes: 10, channels: ["email", "sms"] },
OWNER: { enabled: true, afterMinutes: 30, channels: ["sms"] },
},
rules: EVENT_TYPES.map((eventType) => ({
id: eventType,
eventType,
roles: {
MEMBER: { enabled: true, afterMinutes: 0, channels: ["email"] },
ADMIN: { enabled: true, afterMinutes: 10, channels: ["email", "sms"] },
OWNER: { enabled: true, afterMinutes: 30, channels: ["sms"] },
},
repeatMinutes: 15,
})),
};
export function normalizeAlertPolicy(raw: unknown): AlertPolicy {
const parsed = AlertPolicySchema.safeParse(raw);
if (parsed.success) return parsed.data;
return DEFAULT_POLICY;
}
export function isRoleName(value: string) {
return ROLE_NAMES.includes(value as (typeof ROLE_NAMES)[number]);
}
export function isChannel(value: string) {
return CHANNELS.includes(value as (typeof CHANNELS)[number]);
}

View File

@@ -9,10 +9,11 @@
"common.close": "Close",
"common.save": "Save",
"common.copy": "Copy",
"nav.overview": "Overview",
"nav.machines": "Machines",
"nav.reports": "Reports",
"nav.settings": "Settings",
"nav.overview": "Overview",
"nav.machines": "Machines",
"nav.reports": "Reports",
"nav.alerts": "Alerts",
"nav.settings": "Settings",
"sidebar.productTitle": "MIS",
"sidebar.productSubtitle": "Control Tower",
"sidebar.userFallback": "User",
@@ -168,11 +169,11 @@
"machine.detail.tooltip.deviation": "Deviation",
"machine.detail.kpi.updated": "Updated {time}",
"machine.detail.currentWorkOrder": "Current Work Order",
"machine.detail.recentEvents": "Recent Events",
"machine.detail.recentEvents": "Critical Events",
"machine.detail.noEvents": "No events yet.",
"machine.detail.cycleTarget": "Cycle target",
"machine.detail.mini.events": "Detected Events",
"machine.detail.mini.events.subtitle": "Count by type (cycles)",
"machine.detail.mini.events.subtitle": "Canonical events (all)",
"machine.detail.mini.deviation": "Actual vs Standard Cycle",
"machine.detail.mini.deviation.subtitle": "Average deviation",
"machine.detail.mini.impact": "Production Impact",
@@ -217,7 +218,59 @@
"reports.scrapTrend": "Scrap Trend",
"reports.topLossDrivers": "Top Loss Drivers",
"reports.qualitySummary": "Quality Summary",
"reports.notes": "Notes for Ops",
"reports.notes": "Notes for Ops",
"alerts.title": "Alerts",
"alerts.subtitle": "Escalation policies, channels, and contacts.",
"alerts.comingSoon": "Alert configuration UI is coming soon.",
"alerts.loading": "Loading alerts...",
"alerts.error.loadPolicy": "Failed to load alert policy.",
"alerts.error.savePolicy": "Failed to save alert policy.",
"alerts.error.loadContacts": "Failed to load alert contacts.",
"alerts.error.saveContacts": "Failed to save alert contact.",
"alerts.error.deleteContact": "Failed to delete alert contact.",
"alerts.error.createContact": "Failed to create alert contact.",
"alerts.policy.title": "Alert policy",
"alerts.policy.subtitle": "Configure escalation by role, channel, and duration.",
"alerts.policy.save": "Save policy",
"alerts.policy.saving": "Saving...",
"alerts.policy.defaults": "Default escalation (per role)",
"alerts.policy.enabled": "Enabled",
"alerts.policy.afterMinutes": "After minutes",
"alerts.policy.channels": "Channels",
"alerts.policy.repeatMinutes": "Repeat (min)",
"alerts.policy.readOnly": "You can view alert policy settings, but only owners can edit.",
"alerts.policy.defaultsHelp": "Defaults apply when a specific event is reset or not customized.",
"alerts.policy.eventSelectLabel": "Event type",
"alerts.policy.eventSelectHelper": "Adjust escalation rules for a single event type.",
"alerts.policy.applyDefaults": "Apply defaults",
"alerts.event.macrostop": "Macrostop",
"alerts.event.microstop": "Microstop",
"alerts.event.slow-cycle": "Slow cycle",
"alerts.event.offline": "Offline",
"alerts.event.error": "Error",
"alerts.contacts.title": "Alert contacts",
"alerts.contacts.subtitle": "External recipients and role targeting.",
"alerts.contacts.name": "Name",
"alerts.contacts.roleScope": "Role scope",
"alerts.contacts.email": "Email",
"alerts.contacts.phone": "Phone",
"alerts.contacts.eventTypes": "Event types (optional)",
"alerts.contacts.eventTypesPlaceholder": "macrostop, microstop, offline",
"alerts.contacts.eventTypesHelper": "Leave empty to receive all event types.",
"alerts.contacts.add": "Add contact",
"alerts.contacts.creating": "Adding...",
"alerts.contacts.empty": "No alert contacts yet.",
"alerts.contacts.save": "Save",
"alerts.contacts.saving": "Saving...",
"alerts.contacts.delete": "Delete",
"alerts.contacts.deleting": "Deleting...",
"alerts.contacts.active": "Active",
"alerts.contacts.linkedUser": "Linked user (edit in profile)",
"alerts.contacts.role.custom": "Custom",
"alerts.contacts.role.member": "Member",
"alerts.contacts.role.admin": "Admin",
"alerts.contacts.role.owner": "Owner",
"alerts.contacts.readOnly": "You can view contacts, but only owners can add or edit.",
"reports.notes.suggested": "Suggested actions",
"reports.notes.none": "No insights yet. Generate reports after data collection.",
"reports.noTrend": "No trend data yet.",

View File

@@ -9,10 +9,11 @@
"common.close": "Cerrar",
"common.save": "Guardar",
"common.copy": "Copiar",
"nav.overview": "Resumen",
"nav.machines": "Máquinas",
"nav.reports": "Reportes",
"nav.settings": "Configuración",
"nav.overview": "Resumen",
"nav.machines": "Máquinas",
"nav.reports": "Reportes",
"nav.alerts": "Alertas",
"nav.settings": "Configuración",
"sidebar.productTitle": "MIS",
"sidebar.productSubtitle": "Control Tower",
"sidebar.userFallback": "Usuario",
@@ -168,11 +169,11 @@
"machine.detail.tooltip.deviation": "Desviación",
"machine.detail.kpi.updated": "Actualizado {time}",
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
"machine.detail.recentEvents": "Eventos recientes",
"machine.detail.recentEvents": "Eventos críticos",
"machine.detail.noEvents": "Sin eventos aún.",
"machine.detail.cycleTarget": "Ciclo objetivo",
"machine.detail.mini.events": "Eventos detectados",
"machine.detail.mini.events.subtitle": "Conteo por tipo (ciclos)",
"machine.detail.mini.events.subtitle": "Eventos canónicos (todos)",
"machine.detail.mini.deviation": "Ciclo real vs estándar",
"machine.detail.mini.deviation.subtitle": "Desviación promedio",
"machine.detail.mini.impact": "Impacto en producción",
@@ -217,7 +218,59 @@
"reports.scrapTrend": "Tendencia de scrap",
"reports.topLossDrivers": "Principales causas de pérdida",
"reports.qualitySummary": "Resumen de calidad",
"reports.notes": "Notas para operaciones",
"reports.notes": "Notas para operaciones",
"alerts.title": "Alertas",
"alerts.subtitle": "Politicas de escalamiento, canales y contactos.",
"alerts.comingSoon": "La configuracion de alertas estara disponible pronto.",
"alerts.loading": "Cargando alertas...",
"alerts.error.loadPolicy": "No se pudo cargar la politica de alertas.",
"alerts.error.savePolicy": "No se pudo guardar la politica de alertas.",
"alerts.error.loadContacts": "No se pudieron cargar los contactos de alertas.",
"alerts.error.saveContacts": "No se pudo guardar el contacto de alertas.",
"alerts.error.deleteContact": "No se pudo eliminar el contacto de alertas.",
"alerts.error.createContact": "No se pudo crear el contacto de alertas.",
"alerts.policy.title": "Politica de alertas",
"alerts.policy.subtitle": "Configura escalamiento por rol, canal y duracion.",
"alerts.policy.save": "Guardar politica",
"alerts.policy.saving": "Guardando...",
"alerts.policy.defaults": "Escalamiento por defecto (por rol)",
"alerts.policy.enabled": "Habilitado",
"alerts.policy.afterMinutes": "Despues de minutos",
"alerts.policy.channels": "Canales",
"alerts.policy.repeatMinutes": "Repetir (min)",
"alerts.policy.readOnly": "Puedes ver la politica de alertas, pero solo propietarios pueden editar.",
"alerts.policy.defaultsHelp": "Los valores por defecto aplican cuando un evento se reinicia o no se personaliza.",
"alerts.policy.eventSelectLabel": "Tipo de evento",
"alerts.policy.eventSelectHelper": "Ajusta escalamiento para un solo tipo de evento.",
"alerts.policy.applyDefaults": "Aplicar por defecto",
"alerts.event.macrostop": "Macroparo",
"alerts.event.microstop": "Microparo",
"alerts.event.slow-cycle": "Ciclo lento",
"alerts.event.offline": "Fuera de linea",
"alerts.event.error": "Error",
"alerts.contacts.title": "Contactos de alertas",
"alerts.contacts.subtitle": "Destinatarios externos y alcance por rol.",
"alerts.contacts.name": "Nombre",
"alerts.contacts.roleScope": "Rol",
"alerts.contacts.email": "Correo",
"alerts.contacts.phone": "Telefono",
"alerts.contacts.eventTypes": "Tipos de evento (opcional)",
"alerts.contacts.eventTypesPlaceholder": "macroparo, microparo, fuera-de-linea",
"alerts.contacts.eventTypesHelper": "Deja vacío para recibir todos los tipos de evento.",
"alerts.contacts.add": "Agregar contacto",
"alerts.contacts.creating": "Agregando...",
"alerts.contacts.empty": "Sin contactos de alertas.",
"alerts.contacts.save": "Guardar",
"alerts.contacts.saving": "Guardando...",
"alerts.contacts.delete": "Eliminar",
"alerts.contacts.deleting": "Eliminando...",
"alerts.contacts.active": "Activo",
"alerts.contacts.linkedUser": "Usuario vinculado (editar en perfil)",
"alerts.contacts.role.custom": "Personalizado",
"alerts.contacts.role.member": "Miembro",
"alerts.contacts.role.admin": "Admin",
"alerts.contacts.role.owner": "Propietario",
"alerts.contacts.readOnly": "Puedes ver contactos, pero solo propietarios pueden agregar o editar.",
"reports.notes.suggested": "Acciones sugeridas",
"reports.notes.none": "Sin insights todavía. Genera reportes tras recolectar datos.",
"reports.noTrend": "Sin datos de tendencia.",

8
lib/sms.ts Normal file
View File

@@ -0,0 +1,8 @@
type SmsPayload = {
to: string;
body: string;
};
export async function sendSms(_payload: SmsPayload) {
throw new Error("SMS not configured");
}