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, }); } } } } }