Alert system
This commit is contained in:
88
app/api/alerts/contacts/[id]/route.ts
Normal file
88
app/api/alerts/contacts/[id]/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
77
app/api/alerts/contacts/route.ts
Normal file
77
app/api/alerts/contacts/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
23
app/api/alerts/notifications/route.ts
Normal file
23
app/api/alerts/notifications/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
55
app/api/alerts/policy/route.ts
Normal file
55
app/api/alerts/policy/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user