From 0f88207f3facc9d85e6030eaa07eafd99d1c0aa2 Mon Sep 17 00:00:00 2001 From: Marcelo Date: Thu, 15 Jan 2026 21:03:41 +0000 Subject: [PATCH] Alert system --- app/(app)/alerts/page.tsx | 796 ++++++++++++++++++ .../[machineId]/MachineDetailClient.tsx | 81 +- app/(app)/machines/page.tsx | 8 +- app/(app)/overview/page.tsx | 103 +-- app/api/alerts/contacts/[id]/route.ts | 88 ++ app/api/alerts/contacts/route.ts | 77 ++ app/api/alerts/notifications/route.ts | 23 + app/api/alerts/policy/route.ts | 55 ++ app/api/ingest/event/route.ts | 9 + app/api/ingest/heartbeat/route.ts | 4 +- app/api/machines/[machineId]/route.ts | 60 +- app/api/machines/route.ts | 4 +- app/api/me/route.ts | 2 +- components/layout/Sidebar.tsx | 3 +- lib/alerts/engine.ts | 351 ++++++++ lib/alerts/policy.ts | 59 ++ lib/i18n/en.json | 67 +- lib/i18n/es-MX.json | 67 +- lib/sms.ts | 8 + prisma/schema.prisma | 71 ++ 20 files changed, 1791 insertions(+), 145 deletions(-) create mode 100644 app/(app)/alerts/page.tsx create mode 100644 app/api/alerts/contacts/[id]/route.ts create mode 100644 app/api/alerts/contacts/route.ts create mode 100644 app/api/alerts/notifications/route.ts create mode 100644 app/api/alerts/policy/route.ts create mode 100644 lib/alerts/engine.ts create mode 100644 lib/alerts/policy.ts create mode 100644 lib/sms.ts diff --git a/app/(app)/alerts/page.tsx b/app/(app)/alerts/page.tsx new file mode 100644 index 0000000..f9eb1ab --- /dev/null +++ b/app/(app)/alerts/page.tsx @@ -0,0 +1,796 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; + +type RoleName = "MEMBER" | "ADMIN" | "OWNER"; +type Channel = "email" | "sms"; + +type RoleRule = { + enabled: boolean; + afterMinutes: number; + channels: Channel[]; +}; + +type AlertRule = { + id: string; + eventType: string; + roles: Record; + repeatMinutes?: number; +}; + +type AlertPolicy = { + version: number; + defaults: Record; + rules: AlertRule[]; +}; + +type AlertContact = { + id: string; + name: string; + roleScope: string; + email?: string | null; + phone?: string | null; + eventTypes?: string[] | null; + isActive: boolean; + userId?: string | null; +}; + +type ContactDraft = { + name: string; + roleScope: string; + email: string; + phone: string; + eventTypes: string[]; + isActive: boolean; +}; + +const ROLE_ORDER: RoleName[] = ["MEMBER", "ADMIN", "OWNER"]; +const CHANNELS: Channel[] = ["email", "sms"]; +const EVENT_TYPES = [ + { value: "macrostop", labelKey: "alerts.event.macrostop" }, + { value: "microstop", labelKey: "alerts.event.microstop" }, + { value: "slow-cycle", labelKey: "alerts.event.slow-cycle" }, + { value: "offline", labelKey: "alerts.event.offline" }, + { value: "error", labelKey: "alerts.event.error" }, +] as const; + +function normalizeContactDraft(contact: AlertContact): ContactDraft { + return { + name: contact.name, + roleScope: contact.roleScope, + email: contact.email ?? "", + phone: contact.phone ?? "", + eventTypes: Array.isArray(contact.eventTypes) ? contact.eventTypes : [], + isActive: contact.isActive, + }; +} + +export default function AlertsPage() { + const { t } = useI18n(); + const [policy, setPolicy] = useState(null); + const [policyDraft, setPolicyDraft] = useState(null); + const [contacts, setContacts] = useState([]); + const [contactEdits, setContactEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [role, setRole] = useState("MEMBER"); + const [savingPolicy, setSavingPolicy] = useState(false); + const [policyError, setPolicyError] = useState(null); + const [contactsError, setContactsError] = useState(null); + const [savingContactId, setSavingContactId] = useState(null); + const [deletingContactId, setDeletingContactId] = useState(null); + const [selectedEventType, setSelectedEventType] = useState(""); + + const [newContact, setNewContact] = useState({ + name: "", + roleScope: "CUSTOM", + email: "", + phone: "", + eventTypes: [], + isActive: true, + }); + const [creatingContact, setCreatingContact] = useState(false); + const [createError, setCreateError] = useState(null); + + useEffect(() => { + let alive = true; + + async function load() { + setLoading(true); + setPolicyError(null); + setContactsError(null); + try { + const [policyRes, contactsRes, meRes] = await Promise.all([ + fetch("/api/alerts/policy", { cache: "no-store" }), + fetch("/api/alerts/contacts", { cache: "no-store" }), + fetch("/api/me", { cache: "no-store" }), + ]); + const policyJson = await policyRes.json().catch(() => ({})); + const contactsJson = await contactsRes.json().catch(() => ({})); + const meJson = await meRes.json().catch(() => ({})); + + if (!alive) return; + + if (!policyRes.ok || !policyJson?.ok) { + setPolicyError(policyJson?.error || t("alerts.error.loadPolicy")); + } else { + setPolicy(policyJson.policy); + setPolicyDraft(policyJson.policy); + if (!selectedEventType && policyJson.policy?.rules?.length) { + setSelectedEventType(policyJson.policy.rules[0].eventType); + } + } + + if (!contactsRes.ok || !contactsJson?.ok) { + setContactsError(contactsJson?.error || t("alerts.error.loadContacts")); + } else { + setContacts(contactsJson.contacts ?? []); + const nextEdits: Record = {}; + for (const contact of contactsJson.contacts ?? []) { + nextEdits[contact.id] = normalizeContactDraft(contact); + } + setContactEdits(nextEdits); + } + + if (meRes.ok && meJson?.ok && meJson?.membership?.role) { + setRole(String(meJson.membership.role).toUpperCase() as RoleName); + } + } catch { + if (!alive) return; + setPolicyError(t("alerts.error.loadPolicy")); + setContactsError(t("alerts.error.loadContacts")); + } finally { + if (alive) setLoading(false); + } + } + + load(); + return () => { + alive = false; + }; + }, [t]); + + useEffect(() => { + if (!policyDraft?.rules?.length) return; + setSelectedEventType((prev) => { + if (prev && policyDraft.rules.some((rule) => rule.eventType === prev)) { + return prev; + } + return policyDraft.rules[0].eventType; + }); + }, [policyDraft]); + + function updatePolicyDefaults(role: RoleName, patch: Partial) { + setPolicyDraft((prev) => { + if (!prev) return prev; + return { + ...prev, + defaults: { + ...prev.defaults, + [role]: { + ...prev.defaults[role], + ...patch, + }, + }, + }; + }); + } + + function updateRule(eventType: string, patch: Partial) { + setPolicyDraft((prev) => { + if (!prev) return prev; + return { + ...prev, + rules: prev.rules.map((rule) => + rule.eventType === eventType ? { ...rule, ...patch } : rule + ), + }; + }); + } + + function updateRuleRole(eventType: string, role: RoleName, patch: Partial) { + setPolicyDraft((prev) => { + if (!prev) return prev; + return { + ...prev, + rules: prev.rules.map((rule) => { + if (rule.eventType !== eventType) return rule; + return { + ...rule, + roles: { + ...rule.roles, + [role]: { + ...rule.roles[role], + ...patch, + }, + }, + }; + }), + }; + }); + } + + function applyDefaultsToEvent(eventType: string) { + setPolicyDraft((prev) => { + if (!prev) return prev; + return { + ...prev, + rules: prev.rules.map((rule) => { + if (rule.eventType !== eventType) return rule; + return { + ...rule, + roles: { + MEMBER: { ...prev.defaults.MEMBER }, + ADMIN: { ...prev.defaults.ADMIN }, + OWNER: { ...prev.defaults.OWNER }, + }, + }; + }), + }; + }); + } + async function savePolicy() { + if (!policyDraft) return; + setSavingPolicy(true); + setPolicyError(null); + try { + const res = await fetch("/api/alerts/policy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ policy: policyDraft }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || !json?.ok) { + setPolicyError(json?.error || t("alerts.error.savePolicy")); + return; + } + setPolicy(policyDraft); + } catch { + setPolicyError(t("alerts.error.savePolicy")); + } finally { + setSavingPolicy(false); + } + } + + function updateContactDraft(id: string, patch: Partial) { + setContactEdits((prev) => ({ + ...prev, + [id]: { + ...(prev[id] ?? normalizeContactDraft(contacts.find((c) => c.id === id)!)), + ...patch, + }, + })); + } + + async function saveContact(id: string) { + const draft = contactEdits[id]; + if (!draft) return; + setSavingContactId(id); + try { + const eventTypes = draft.eventTypes.length ? draft.eventTypes : null; + const res = await fetch(`/api/alerts/contacts/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: draft.name, + roleScope: draft.roleScope, + email: draft.email || null, + phone: draft.phone || null, + eventTypes, + isActive: draft.isActive, + }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || !json?.ok) { + setContactsError(json?.error || t("alerts.error.saveContacts")); + return; + } + const updated = json.contact as AlertContact; + setContacts((prev) => prev.map((c) => (c.id === id ? updated : c))); + setContactEdits((prev) => ({ ...prev, [id]: normalizeContactDraft(updated) })); + } catch { + setContactsError(t("alerts.error.saveContacts")); + } finally { + setSavingContactId(null); + } + } + + async function deleteContact(id: string) { + setDeletingContactId(id); + try { + const res = await fetch(`/api/alerts/contacts/${id}`, { method: "DELETE" }); + const json = await res.json().catch(() => ({})); + if (!res.ok || !json?.ok) { + setContactsError(json?.error || t("alerts.error.deleteContact")); + return; + } + setContacts((prev) => prev.filter((c) => c.id !== id)); + setContactEdits((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } catch { + setContactsError(t("alerts.error.deleteContact")); + } finally { + setDeletingContactId(null); + } + } + + async function createContact() { + setCreatingContact(true); + setCreateError(null); + try { + const eventTypes = newContact.eventTypes.length ? newContact.eventTypes : null; + const res = await fetch("/api/alerts/contacts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: newContact.name, + roleScope: newContact.roleScope, + email: newContact.email || null, + phone: newContact.phone || null, + eventTypes, + }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || !json?.ok) { + setCreateError(json?.error || t("alerts.error.createContact")); + return; + } + const contact = json.contact as AlertContact; + setContacts((prev) => [contact, ...prev]); + setContactEdits((prev) => ({ ...prev, [contact.id]: normalizeContactDraft(contact) })); + setNewContact({ + name: "", + roleScope: "CUSTOM", + email: "", + phone: "", + eventTypes: [], + isActive: true, + }); + } catch { + setCreateError(t("alerts.error.createContact")); + } finally { + setCreatingContact(false); + } + } + + const policyDirty = useMemo( + () => JSON.stringify(policy) !== JSON.stringify(policyDraft), + [policy, policyDraft] + ); + const canEdit = role === "OWNER"; + + return ( +
+
+

{t("alerts.title")}

+

{t("alerts.subtitle")}

+
+ + {loading && ( +
{t("alerts.loading")}
+ )} + + {!loading && policyError && ( +
+ {policyError} +
+ )} + + {!loading && policyDraft && ( +
+
+
+
{t("alerts.policy.title")}
+
{t("alerts.policy.subtitle")}
+
+ +
+ + {!canEdit && ( +
+ {t("alerts.policy.readOnly")} +
+ )} + +
+
{t("alerts.policy.defaults")}
+
{t("alerts.policy.defaultsHelp")}
+
+ {ROLE_ORDER.map((role) => { + const rule = policyDraft.defaults[role]; + return ( +
+
{role}
+ + +
{t("alerts.policy.channels")}
+
+ {CHANNELS.map((channel) => ( + + ))} +
+
+ ); + })} +
+
+ +
+
+
+
+
{t("alerts.policy.eventSelectLabel")}
+
{t("alerts.policy.eventSelectHelper")}
+
+
+ + +
+
+
+ + {policyDraft.rules + .filter((rule) => rule.eventType === selectedEventType) + .map((rule) => ( +
+
+
+ {t(`alerts.event.${rule.eventType}`)} +
+ +
+ +
+ {ROLE_ORDER.map((role) => { + const roleRule = rule.roles[role]; + return ( +
+
{role}
+ + +
{t("alerts.policy.channels")}
+
+ {CHANNELS.map((channel) => ( + + ))} +
+
+ ); + })} +
+
+ ))} +
+
+ )} + +
+
+
+
{t("alerts.contacts.title")}
+
{t("alerts.contacts.subtitle")}
+
+
+ + {!canEdit && ( +
+ {t("alerts.contacts.readOnly")} +
+ )} + + {contactsError && ( +
+ {contactsError} +
+ )} + +
+ + + + + +
+ + {createError && ( +
+ {createError} +
+ )} + +
+ +
+ +
+ {contacts.length === 0 && ( +
{t("alerts.contacts.empty")}
+ )} + {contacts.map((contact) => { + const draft = contactEdits[contact.id] ?? normalizeContactDraft(contact); + const locked = !!contact.userId; + return ( +
+
+ + + + + + +
+
+ + + {locked && ( + {t("alerts.contacts.linkedUser")} + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index 14e4ad8..c4afd8a 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -21,6 +21,7 @@ import { useI18n } from "@/lib/i18n/useI18n"; type Heartbeat = { ts: string; + tsServer?: string | null; status: string; message?: string | null; ip?: string | null; @@ -250,6 +251,9 @@ export default function MachineDetailClient() { const [loading, setLoading] = useState(true); const [machine, setMachine] = useState(null); const [events, setEvents] = useState([]); + const [detectedEvents, setDetectedEvents] = useState([]); + const [eventsCountAll, setEventsCountAll] = useState(null); + const [detectedEventsLoading, setDetectedEventsLoading] = useState(false); const [error, setError] = useState(null); const [cycles, setCycles] = useState([]); const [thresholds, setThresholds] = useState(null); @@ -300,7 +304,7 @@ export default function MachineDetailClient() { async function load() { try { - const res = await fetch(`/api/machines/${machineId}?windowSec=10800`, { + const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, { cache: "no-store", credentials: "include", }); @@ -316,6 +320,7 @@ export default function MachineDetailClient() { setMachine(json.machine ?? null); setEvents(json.events ?? []); + setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null); setCycles(json.cycles ?? []); setThresholds(json.thresholds ?? null); setActiveStoppage(json.activeStoppage ?? null); @@ -341,6 +346,32 @@ export default function MachineDetailClient() { return () => clearInterval(timer); }, []); + useEffect(() => { + if (open !== "events" || !machineId) return; + + let alive = true; + setDetectedEventsLoading(true); + + fetch(`/api/machines/${machineId}?events=all&eventsOnly=1&eventsWindowSec=21600`, { + cache: "no-store", + credentials: "include", + }) + .then((res) => res.json()) + .then((json) => { + if (!alive) return; + setDetectedEvents(json.events ?? []); + setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : eventsCountAll); + }) + .catch(() => {}) + .finally(() => { + if (alive) setDetectedEventsLoading(false); + }); + + return () => { + alive = false; + }; + }, [machineId, open, eventsCountAll]); + async function parseWorkOrdersFile(file: File) { const name = file.name.toLowerCase(); if (name.endsWith(".csv")) { @@ -512,7 +543,8 @@ export default function MachineDetailClient() { const hb = machine?.latestHeartbeat ?? null; const kpi = machine?.latestKpi ?? null; - const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]); + const hbTs = hb?.tsServer ?? hb?.ts; + const offline = useMemo(() => isOffline(hbTs), [hbTs]); const normalizedStatus = normalizeStatus(hb?.status); const statusLabel = offline ? t("machine.detail.status.offline") @@ -526,7 +558,7 @@ export default function MachineDetailClient() { const machineCode = machine?.code ?? t("common.na"); const machineLocation = machine?.location ?? t("common.na"); const lastSeenLabel = t("machine.detail.lastSeen", { - time: hb?.ts ? timeAgo(hb.ts) : t("common.never"), + time: hbTs ? timeAgo(hbTs) : t("common.never"), }); const ActiveRing = (props: any) => { @@ -814,7 +846,7 @@ export default function MachineDetailClient() { }; } - const windowSec = 10800; + const windowSec = 3600; const end = rows[rows.length - 1].t; const start = end - windowSec * 1000; @@ -1027,7 +1059,7 @@ export default function MachineDetailClient() { setOpen("events")} /> setOpen(null)} title={t("machine.detail.modal.events")}>
- {cycleDerived.mapped - .filter((row) => row.bucket !== "normal" && row.bucket !== "unknown") - .slice() - .reverse() - .map((row, idx) => { - const meta = BUCKET[row.bucket as keyof typeof BUCKET]; + {detectedEventsLoading ? ( +
{t("machine.detail.loading")}
+ ) : detectedEvents.length === 0 ? ( +
{t("machine.detail.noEvents")}
+ ) : ( + detectedEvents.map((event) => { + const bucket = + event.eventType === "macrostop" + ? "macrostop" + : event.eventType === "microstop" + ? "microstop" + : event.eventType === "slow-cycle" + ? "slow" + : "unknown"; + const meta = BUCKET[bucket as keyof typeof BUCKET] ?? BUCKET.unknown; return (
@@ -1066,20 +1107,20 @@ export default function MachineDetailClient() {
- {t(meta.labelKey)} - - - {row.actual?.toFixed(2)}s - {row.ideal != null ? ` (${t("machine.detail.modal.standardCycle")} ${row.ideal.toFixed(2)}s)` : ""} + {formatEventType(event.eventType)} + {event.title}
+ {event.description ? ( +
{event.description}
+ ) : null}
- -
{timeAgo(row.ts)}
+
{timeAgo(event.ts)}
); - })} + }) + )}
diff --git a/app/(app)/machines/page.tsx b/app/(app)/machines/page.tsx index ef47e6a..2b9f0e0 100644 --- a/app/(app)/machines/page.tsx +++ b/app/(app)/machines/page.tsx @@ -11,6 +11,7 @@ type MachineRow = { location?: string | null; latestHeartbeat: null | { ts: string; + tsServer?: string | null; status: string; message?: string | null; ip?: string | null; @@ -267,10 +268,11 @@ export default function MachinesPage() {
{(!loading ? machines : []).map((m) => { const hb = m.latestHeartbeat; - const offline = isOffline(hb?.ts); + const hbTs = hb?.tsServer ?? hb?.ts; + const offline = isOffline(hbTs); const normalizedStatus = normalizeStatus(hb?.status); const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown")); - const lastSeen = secondsAgo(hb?.ts, locale, t("common.never")); + const lastSeen = secondsAgo(hbTs, locale, t("common.never")); return ( ); } - - diff --git a/app/(app)/overview/page.tsx b/app/(app)/overview/page.tsx index 7d69fd8..9c1fbe5 100644 --- a/app/(app)/overview/page.tsx +++ b/app/(app)/overview/page.tsx @@ -6,6 +6,7 @@ import { useI18n } from "@/lib/i18n/useI18n"; type Heartbeat = { ts: string; + tsServer?: string | null; status: string; message?: string | null; ip?: string | null; @@ -35,11 +36,6 @@ type MachineRow = { latestKpi?: Kpi | null; }; -type Thresholds = { - stoppageMultiplier: number; - macroStoppageMultiplier: number; -}; - type EventRow = { id: string; ts: string; @@ -51,31 +47,11 @@ type EventRow = { requiresAck: boolean; machineId?: string; machineName?: string; - source: "ingested" | "derived"; -}; - -type CycleRow = { - ts: string; - t: number; - cycleCount: number | null; - actual: number; - ideal: number | null; + source: "ingested"; }; const OFFLINE_MS = 30000; -const EVENT_WINDOW_SEC = 1800; const MAX_EVENT_MACHINES = 6; -const DEFAULT_MICRO_MULT = 1.5; -const DEFAULT_MACRO_MULT = 5; - -function resolveMultipliers(thresholds?: Thresholds | null) { - const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT); - const macro = Math.max( - micro, - Number(thresholds?.macroStoppageMultiplier ?? DEFAULT_MACRO_MULT) - ); - return { micro, macro }; -} function secondsAgo(ts: string | undefined, locale: string, fallback: string) { if (!ts) return fallback; @@ -97,6 +73,10 @@ function normalizeStatus(status?: string) { return s; } +function heartbeatTime(hb?: Heartbeat | null) { + return hb?.tsServer ?? hb?.ts; +} + function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "--"; return `${v.toFixed(1)}%`; @@ -115,41 +95,8 @@ function severityClass(sev?: string) { return "bg-white/10 text-zinc-200"; } -function sourceClass(src: EventRow["source"]) { - return src === "ingested" - ? "bg-white/10 text-zinc-200" - : "bg-emerald-500/15 text-emerald-300"; -} - -function classifyDerivedEvent(c: CycleRow, thresholds?: Thresholds | null) { - if (c.ideal == null || c.ideal <= 0 || c.actual <= 0) return null; - if (c.actual <= c.ideal) return null; - const { micro, macro } = resolveMultipliers(thresholds); - const extra = c.actual - c.ideal; - let eventType = "slow-cycle"; - let severity = "warning"; - if (c.actual < c.ideal * micro) { - eventType = "slow-cycle"; - severity = "warning"; - } else if (c.actual < c.ideal * macro) { - eventType = "microstop"; - severity = "warning"; - } else { - eventType = "macrostop"; - severity = "critical"; - } - - return { - eventType, - severity, - title: - eventType === "macrostop" - ? "Macrostop Detected" - : eventType === "microstop" - ? "Microstop Detected" - : "Slow Cycle Detected", - description: `Cycle ${c.actual.toFixed(2)}s (ideal ${c.ideal.toFixed(2)}s)`, - }; +function sourceClass(_src: EventRow["source"]) { + return "bg-white/10 text-zinc-200"; } export default function OverviewPage() { @@ -196,9 +143,11 @@ export default function OverviewPage() { setEventsLoading(true); const sorted = [...machines].sort((a, b) => { - const at = a.latestHeartbeat?.ts ? new Date(a.latestHeartbeat.ts).getTime() : 0; - const bt = b.latestHeartbeat?.ts ? new Date(b.latestHeartbeat.ts).getTime() : 0; - return bt - at; + const at = heartbeatTime(a.latestHeartbeat); + const bt = heartbeatTime(b.latestHeartbeat); + const atMs = at ? new Date(at).getTime() : 0; + const btMs = bt ? new Date(bt).getTime() : 0; + return btMs - atMs; }); const targets = sorted.slice(0, MAX_EVENT_MACHINES); @@ -206,7 +155,7 @@ export default function OverviewPage() { try { const results = await Promise.all( targets.map(async (m) => { - const res = await fetch(`/api/machines/${m.id}?windowSec=${EVENT_WINDOW_SEC}`, { + const res = await fetch(`/api/machines/${m.id}?events=critical&eventsOnly=1`, { cache: "no-store", signal: controller.signal, }); @@ -230,24 +179,6 @@ export default function OverviewPage() { }); } - const cycles: CycleRow[] = Array.isArray(payload?.cycles) ? payload.cycles : []; - for (const c of cycles.slice(-120)) { - const derived = classifyDerivedEvent(c, payload?.thresholds); - if (!derived) continue; - combined.push({ - id: `derived-${machine.id}-${c.t}`, - ts: c.ts, - topic: derived.eventType, - eventType: derived.eventType, - severity: derived.severity, - title: derived.title, - description: derived.description, - requiresAck: false, - machineId: machine.id, - machineName: machine.name, - source: "derived", - }); - } } const seen = new Set(); @@ -295,7 +226,7 @@ export default function OverviewPage() { for (const m of machines) { const hb = m.latestHeartbeat; - const offline = isOffline(hb?.ts); + const offline = isOffline(heartbeatTime(hb)); if (!offline) online += 1; const status = normalizeStatus(hb?.status); @@ -348,7 +279,7 @@ export default function OverviewPage() { const list = machines .map((m) => { const hb = m.latestHeartbeat; - const offline = isOffline(hb?.ts); + const offline = isOffline(heartbeatTime(hb)); const k = m.latestKpi; const oee = k?.oee ?? null; let score = 0; @@ -512,7 +443,7 @@ export default function OverviewPage() {
- {secondsAgo(machine.latestHeartbeat?.ts, locale, t("common.never"))} + {secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
diff --git a/app/api/alerts/contacts/[id]/route.ts b/app/api/alerts/contacts/[id]/route.ts new file mode 100644 index 0000000..fde53ad --- /dev/null +++ b/app/api/alerts/contacts/[id]/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; + +const roleScopeSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim().toUpperCase() : value), + z.enum(["MEMBER", "ADMIN", "OWNER", "CUSTOM"]) +); + +const contactPatchSchema = z.object({ + name: z.string().trim().min(1).max(120).optional(), + roleScope: roleScopeSchema.optional(), + email: z.string().trim().email().optional().nullable(), + phone: z.string().trim().min(6).max(40).optional().nullable(), + userId: z.string().uuid().optional().nullable(), + eventTypes: z.array(z.string().trim().min(1)).optional().nullable(), + isActive: z.boolean().optional(), +}); + +function canManageAlerts(role?: string | null) { + return role === "OWNER"; +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageAlerts(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const parsed = contactPatchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid contact payload" }, { status: 400 }); + } + + const { id } = await params; + const existing = await prisma.alertContact.findFirst({ + where: { id, orgId: session.orgId }, + }); + if (!existing) { + return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); + } + + const { userId: _userId, eventTypes, ...updateData } = parsed.data; + const normalizedEventTypes = + eventTypes === null ? Prisma.DbNull : eventTypes ?? undefined; + const data = normalizedEventTypes === undefined + ? updateData + : { ...updateData, eventTypes: normalizedEventTypes }; + const updated = await prisma.alertContact.update({ + where: { id }, + data, + }); + + return NextResponse.json({ ok: true, contact: updated }); +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageAlerts(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const existing = await prisma.alertContact.findFirst({ + where: { id, orgId: session.orgId }, + }); + if (!existing) { + return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); + } + + await prisma.alertContact.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/alerts/contacts/route.ts b/app/api/alerts/contacts/route.ts new file mode 100644 index 0000000..fc6b682 --- /dev/null +++ b/app/api/alerts/contacts/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; + +const roleScopeSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim().toUpperCase() : value), + z.enum(["MEMBER", "ADMIN", "OWNER", "CUSTOM"]) +); + +const contactSchema = z.object({ + name: z.string().trim().min(1).max(120), + roleScope: roleScopeSchema, + email: z.string().trim().email().optional().nullable(), + phone: z.string().trim().min(6).max(40).optional().nullable(), + userId: z.string().uuid().optional().nullable(), + eventTypes: z.array(z.string().trim().min(1)).optional().nullable(), +}); + +function canManageAlerts(role?: string | null) { + return role === "OWNER"; +} + +export async function GET() { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const contacts = await prisma.alertContact.findMany({ + where: { orgId: session.orgId }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ ok: true, contacts }); +} + +export async function POST(req: Request) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageAlerts(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const parsed = contactSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid contact payload" }, { status: 400 }); + } + + const data = parsed.data; + const hasChannel = !!(data.email || data.phone); + if (!data.userId && !hasChannel) { + return NextResponse.json({ ok: false, error: "email or phone required for external contact" }, { status: 400 }); + } + + const eventTypes = + data.eventTypes === null ? Prisma.DbNull : data.eventTypes ?? undefined; + + const contact = await prisma.alertContact.create({ + data: { + orgId: session.orgId, + userId: data.userId ?? null, + name: data.name, + roleScope: data.roleScope, + email: data.email ?? null, + phone: data.phone ?? null, + eventTypes, + }, + }); + + return NextResponse.json({ ok: true, contact }); +} diff --git a/app/api/alerts/notifications/route.ts b/app/api/alerts/notifications/route.ts new file mode 100644 index 0000000..6f510d5 --- /dev/null +++ b/app/api/alerts/notifications/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; + +export async function GET(req: Request) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const url = new URL(req.url); + const machineId = url.searchParams.get("machineId") || undefined; + const limit = Math.min(Number(url.searchParams.get("limit") ?? "50"), 200); + + const notifications = await prisma.alertNotification.findMany({ + where: { + orgId: session.orgId, + ...(machineId ? { machineId } : {}), + }, + orderBy: { sentAt: "desc" }, + take: Number.isFinite(limit) ? limit : 50, + }); + + return NextResponse.json({ ok: true, notifications }); +} diff --git a/app/api/alerts/policy/route.ts b/app/api/alerts/policy/route.ts new file mode 100644 index 0000000..2aee52d --- /dev/null +++ b/app/api/alerts/policy/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { AlertPolicySchema, DEFAULT_POLICY } from "@/lib/alerts/policy"; + +function canManageAlerts(role?: string | null) { + return role === "OWNER"; +} + +export async function GET() { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + let policy = await prisma.alertPolicy.findUnique({ + where: { orgId: session.orgId }, + select: { policyJson: true }, + }); + + if (!policy) { + await prisma.alertPolicy.create({ + data: { orgId: session.orgId, policyJson: DEFAULT_POLICY }, + }); + policy = { policyJson: DEFAULT_POLICY }; + } + + const parsed = AlertPolicySchema.safeParse(policy.policyJson); + return NextResponse.json({ ok: true, policy: parsed.success ? parsed.data : DEFAULT_POLICY }); +} + +export async function PUT(req: Request) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageAlerts(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const parsed = AlertPolicySchema.safeParse(body?.policy ?? body); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid policy payload" }, { status: 400 }); + } + + await prisma.alertPolicy.upsert({ + where: { orgId: session.orgId }, + create: { orgId: session.orgId, policyJson: parsed.data, updatedBy: session.userId }, + update: { policyJson: parsed.data, updatedBy: session.userId }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index 32ffc52..3bbb33e 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { z } from "zod"; +import { evaluateAlertsForEvent } from "@/lib/alerts/engine"; const normalizeType = (t: any) => String(t ?? "") @@ -29,6 +30,8 @@ const ALLOWED_TYPES = new Set([ "slow-cycle", "microstop", "macrostop", + "offline", + "error", "oee-drop", "quality-spike", "performance-degradation", @@ -212,6 +215,12 @@ export async function POST(req: Request) { }); created.push({ id: row.id, ts: row.ts, eventType: row.eventType }); + + try { + await evaluateAlertsForEvent(row.id); + } catch (err) { + console.error("[alerts] evaluation failed", err); + } } return NextResponse.json({ ok: true, createdCount: created.length, created, skippedCount: skipped.length, skipped }); diff --git a/app/api/ingest/heartbeat/route.ts b/app/api/ingest/heartbeat/route.ts index 549fd83..06454f9 100644 --- a/app/api/ingest/heartbeat/route.ts +++ b/app/api/ingest/heartbeat/route.ts @@ -86,6 +86,7 @@ export async function POST(req: Request) { // 5) Store heartbeat // Keep your legacy fields, but store meta fields too. + const tsServerNow = new Date(); const hb = await prisma.machineHeartbeat.create({ data: { orgId, @@ -95,6 +96,7 @@ export async function POST(req: Request) { schemaVersion, seq, ts: tsDeviceDate, + tsServer: tsServerNow, // Legacy payload compatibility status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"), @@ -111,7 +113,7 @@ export async function POST(req: Request) { schemaVersion, seq, tsDevice: tsDeviceDate, - tsServer: new Date(), + tsServer: tsServerNow, }, }); diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts index 00e81c3..568db52 100644 --- a/app/api/machines/[machineId]/route.ts +++ b/app/api/machines/[machineId]/route.ts @@ -183,6 +183,12 @@ export async function GET( return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } + const url = new URL(_req.url); + const eventsMode = url.searchParams.get("events") ?? "all"; + const eventsOnly = url.searchParams.get("eventsOnly") === "1"; + const eventsWindowSec = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); // default 6h + const eventsWindowStart = new Date(Date.now() - Math.max(0, eventsWindowSec) * 1000); + const { machineId } = await params; const machine = await prisma.machine.findFirst({ @@ -193,9 +199,9 @@ export async function GET( code: true, location: true, heartbeats: { - orderBy: { ts: "desc" }, + orderBy: { tsServer: "desc" }, take: 1, - select: { ts: true, status: true, message: true, ip: true, fwVersion: true }, + select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true }, }, kpiSnapshots: { orderBy: { ts: "desc" }, @@ -236,6 +242,7 @@ export async function GET( where: { orgId: session.orgId, machineId, + ts: { gte: eventsWindowStart }, }, orderBy: { ts: "desc" }, take: 100, // pull more, we'll filter after normalization @@ -257,24 +264,43 @@ export async function GET( normalizeEvent(row, { microMultiplier, macroMultiplier }) ); -const ALLOWED_TYPES = new Set([ - "slow-cycle", - "microstop", - "macrostop", - "oee-drop", - "quality-spike", - "performance-degradation", - "predictive-oee-decline", -]); + const ALLOWED_TYPES = new Set([ + "slow-cycle", + "microstop", + "macrostop", + "offline", + "error", + "oee-drop", + "quality-spike", + "performance-degradation", + "predictive-oee-decline", + "alert-delivery-failed", + ]); -const events = normalized - .filter((e) => ALLOWED_TYPES.has(e.eventType)) - // drop severity gating so recent info events appear - .slice(0, 30); + const allEvents = normalized.filter((e) => ALLOWED_TYPES.has(e.eventType)); + + const isCritical = (event: (typeof allEvents)[number]) => { + const severity = String(event.severity ?? "").toLowerCase(); + return ( + event.eventType === "macrostop" || + event.requiresAck === true || + severity === "critical" || + severity === "error" || + severity === "high" + ); + }; + + const eventsFiltered = eventsMode === "critical" ? allEvents.filter(isCritical) : allEvents; + const events = eventsFiltered.slice(0, 30); + const eventsCountAll = allEvents.length; + const eventsCountCritical = allEvents.filter(isCritical).length; + + if (eventsOnly) { + return NextResponse.json({ ok: true, events, eventsCountAll, eventsCountCritical }); + } // ---- cycles window ---- -const url = new URL(_req.url); const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h const latestKpi = machine.kpiSnapshots[0] ?? null; @@ -375,6 +401,8 @@ const cycles = rawCycles }, activeStoppage, events, + eventsCountAll, + eventsCountCritical, cycles, }); diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts index e1c2aed..f4d4cde 100644 --- a/app/api/machines/route.ts +++ b/app/api/machines/route.ts @@ -44,9 +44,9 @@ export async function GET() { createdAt: true, updatedAt: true, heartbeats: { - orderBy: { ts: "desc" }, + orderBy: { tsServer: "desc" }, take: 1, - select: { ts: true, status: true, message: true, ip: true, fwVersion: true }, + select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true }, }, kpiSnapshots: { orderBy: { ts: "desc" }, diff --git a/app/api/me/route.ts b/app/api/me/route.ts index ca55f44..3c3d4b3 100644 --- a/app/api/me/route.ts +++ b/app/api/me/route.ts @@ -14,7 +14,7 @@ export async function GET() { const user = await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, email: true, name: true }, + select: { id: true, email: true, name: true, phone: true }, }); const org = await prisma.org.findUnique({ diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 29834c5..194bb0d 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -3,13 +3,14 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { BarChart3, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; +import { BarChart3, Bell, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; import { useI18n } from "@/lib/i18n/useI18n"; const items = [ { href: "/overview", labelKey: "nav.overview", icon: LayoutGrid }, { href: "/machines", labelKey: "nav.machines", icon: Wrench }, { href: "/reports", labelKey: "nav.reports", icon: BarChart3 }, + { href: "/alerts", labelKey: "nav.alerts", icon: Bell }, { href: "/settings", labelKey: "nav.settings", icon: Settings }, ] as const; diff --git a/lib/alerts/engine.ts b/lib/alerts/engine.ts new file mode 100644 index 0000000..2e22537 --- /dev/null +++ b/lib/alerts/engine.ts @@ -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 { + 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, "
"); + 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(); + + 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, + }); + } + } + } + } +} diff --git a/lib/alerts/policy.ts b/lib/alerts/policy.ts new file mode 100644 index 0000000..d906465 --- /dev/null +++ b/lib/alerts/policy.ts @@ -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; + +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]); +} diff --git a/lib/i18n/en.json b/lib/i18n/en.json index 0b7ac36..723f3eb 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -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.", diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json index 3977096..9001e2b 100644 --- a/lib/i18n/es-MX.json +++ b/lib/i18n/es-MX.json @@ -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.", diff --git a/lib/sms.ts b/lib/sms.ts new file mode 100644 index 0000000..7067684 --- /dev/null +++ b/lib/sms.ts @@ -0,0 +1,8 @@ +type SmsPayload = { + to: string; + body: string; +}; + +export async function sendSms(_payload: SmsPayload) { + throw new Error("SMS not configured"); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 22ed03c..04b7f8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,12 +25,16 @@ model Org { machineSettings MachineSettings[] settingsAudits SettingsAudit[] invites OrgInvite[] + alertPolicies AlertPolicy[] + alertContacts AlertContact[] + alertNotifications AlertNotification[] } model User { id String @id @default(uuid()) email String @unique name String? + phone String? @map("phone") passwordHash String isActive Boolean @default(true) createdAt DateTime @default(now()) @@ -41,6 +45,8 @@ model User { orgs OrgUser[] sessions Session[] sentInvites OrgInvite[] @relation("OrgInviteInviter") + alertContacts AlertContact[] + alertNotifications AlertNotification[] } model OrgUser { @@ -123,6 +129,7 @@ model Machine { workOrders MachineWorkOrder[] settings MachineSettings? settingsAudits SettingsAudit[] + alertNotifications AlertNotification[] @@unique([orgId, name]) @@index([orgId]) @@ -306,6 +313,70 @@ model OrgSettings { @@map("org_settings") } +model AlertPolicy { + id String @id @default(uuid()) + orgId String @map("org_id") + policyJson Json @map("policy_json") + updatedAt DateTime @updatedAt @map("updated_at") + updatedBy String? @map("updated_by") + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + + @@unique([orgId]) + @@index([orgId]) + @@map("alert_policies") +} + +model AlertContact { + id String @id @default(uuid()) + orgId String @map("org_id") + userId String? @map("user_id") + name String + roleScope String @map("role_scope") // MEMBER | ADMIN | OWNER | CUSTOM + email String? + phone String? + eventTypes Json? @map("event_types") // optional allowlist (array of strings) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + notifications AlertNotification[] + + @@unique([orgId, userId]) + @@index([orgId]) + @@index([orgId, roleScope]) + @@map("alert_contacts") +} + +model AlertNotification { + id String @id @default(uuid()) + orgId String @map("org_id") + machineId String @map("machine_id") + eventId String @map("event_id") + eventType String @map("event_type") + ruleId String @map("rule_id") + role String + channel String + contactId String? @map("contact_id") + userId String? @map("user_id") + sentAt DateTime @default(now()) @map("sent_at") + status String + error String? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade) + contact AlertContact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([orgId, machineId, sentAt]) + @@index([orgId, eventId, role, channel]) + @@index([userId]) + @@index([contactId]) + @@map("alert_notifications") +} + model OrgShift { id String @id @default(uuid()) orgId String @map("org_id")