Files
MIS-Contro-Tower/app/api/ingest/event/route.ts
Marcelo 1fe0b4dbf9 V1.1
2025-12-29 18:43:39 +00:00

337 lines
8.9 KiB
TypeScript

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { normalizeEventV1 } from "@/lib/contracts/v1";
function getClientIp(req: Request) {
const xf = req.headers.get("x-forwarded-for");
if (xf) return xf.split(",")[0]?.trim() || null;
return req.headers.get("x-real-ip") || null;
}
function parseSeqToBigInt(seq: unknown): bigint | null {
if (seq === null || seq === undefined) return null;
if (typeof seq === "number") {
if (!Number.isInteger(seq) || seq < 0) return null;
return BigInt(seq);
}
if (typeof seq === "string" && /^\d+$/.test(seq)) return BigInt(seq);
return null;
}
const normalizeType = (t: any) =>
String(t ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
const CANON_TYPE: Record<string, string> = {
// Node-RED
"production-stopped": "stop",
"oee-drop": "oee-drop",
"quality-spike": "quality-spike",
"predictive-oee-decline": "predictive-oee-decline",
"performance-degradation": "performance-degradation",
// legacy / synonyms
"macroparo": "macrostop",
"macro-stop": "macrostop",
"microparo": "microstop",
"micro-paro": "microstop",
"down": "stop",
};
const ALLOWED_TYPES = new Set([
"slow-cycle",
"microstop",
"macrostop",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
]);
// thresholds for stop classification (tune later / move to machine config)
const MICROSTOP_SEC = 60;
const MACROSTOP_SEC = 300;
export async function POST(req: Request) {
const endpoint = "/api/ingest/event";
const ip = getClientIp(req);
const userAgent = req.headers.get("user-agent");
let rawBody: any = null;
let orgId: string | null = null;
let machineId: string | null = null;
let schemaVersion: string | null = null;
let seq: bigint | null = null;
let tsDeviceDate: Date | null = null;
try {
// 1) Auth header exists
const apiKey = req.headers.get("x-api-key");
if (!apiKey) {
await prisma.ingestLog.create({
data: {
endpoint,
ok: false,
status: 401,
errorCode: "MISSING_API_KEY",
errorMsg: "Missing api key",
ip,
userAgent,
},
});
return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
}
// 2) Parse JSON
rawBody = await req.json().catch(() => null);
// 3) Reject arrays at the contract boundary (Phase 0 rule)
// Edge MUST split arrays into one event per POST.
if (rawBody?.event && Array.isArray(rawBody.event)) {
await prisma.ingestLog.create({
data: {
endpoint,
ok: false,
status: 400,
errorCode: "EVENT_ARRAY_NOT_ALLOWED",
errorMsg: "Edge must split arrays; send one event per request.",
body: rawBody,
machineId: rawBody?.machineId ? String(rawBody.machineId) : null,
ip,
userAgent,
},
});
return NextResponse.json(
{ ok: false, error: "Invalid payload", detail: "event array not allowed; split on edge" },
{ status: 400 }
);
}
// 4) Normalize to v1 (legacy tolerated)
const normalized = normalizeEventV1(rawBody);
if (!normalized.ok) {
await prisma.ingestLog.create({
data: {
endpoint,
ok: false,
status: 400,
errorCode: "INVALID_PAYLOAD",
errorMsg: normalized.error,
body: rawBody,
ip,
userAgent,
},
});
return NextResponse.json({ ok: false, error: "Invalid payload", detail: normalized.error }, { status: 400 });
}
const body = normalized.value;
schemaVersion = body.schemaVersion;
machineId = body.machineId;
seq = parseSeqToBigInt(body.seq);
tsDeviceDate = new Date(body.tsDevice);
// 5) Authorize machineId + apiKey
const machine = await prisma.machine.findFirst({
where: { id: machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) {
await prisma.ingestLog.create({
data: {
endpoint,
ok: false,
status: 401,
errorCode: "UNAUTHORIZED",
errorMsg: "Unauthorized (machineId/apiKey mismatch)",
body: rawBody,
machineId,
schemaVersion,
seq,
tsDevice: tsDeviceDate,
ip,
userAgent,
},
});
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
orgId = machine.orgId;
// 6) Canonicalize + classify type (keep for now; later move to edge in A1)
const ev = body.event;
const rawType =
(ev as any).eventType ?? (ev as any).anomaly_type ?? (ev as any).topic ?? (body as any).topic ?? "";
const typ0 = normalizeType(rawType);
const typ = CANON_TYPE[typ0] ?? typ0;
let finalType = typ;
// Stop classification -> microstop/macrostop
if (typ === "stop") {
const stopSec =
(typeof (ev as any)?.data?.stoppage_duration_seconds === "number" && (ev as any).data.stoppage_duration_seconds) ||
(typeof (ev as any)?.data?.stop_duration_seconds === "number" && (ev as any).data.stop_duration_seconds) ||
null;
if (stopSec != null) {
finalType = stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop";
} else {
finalType = "microstop";
}
}
if (!ALLOWED_TYPES.has(finalType)) {
await prisma.ingestLog.create({
data: {
orgId,
machineId: machine.id,
endpoint,
ok: false,
status: 400,
errorCode: "TYPE_NOT_ALLOWED",
errorMsg: `Event type not allowed: ${finalType}`,
schemaVersion,
seq,
tsDevice: tsDeviceDate,
body: rawBody,
ip,
userAgent,
},
});
return NextResponse.json(
{ ok: false, error: "Invalid event type", detail: finalType },
{ status: 400 }
);
}
// Determine severity
let sev = String((ev as any).severity ?? "").trim().toLowerCase();
if (!sev) sev = "warning";
const title =
String((ev as any).title ?? "").trim() ||
(finalType === "slow-cycle"
? "Slow Cycle Detected"
: finalType === "macrostop"
? "Macrostop Detected"
: finalType === "microstop"
? "Microstop Detected"
: "Event");
const description = (ev as any).description ? String((ev as any).description) : null;
// store full blob
const rawData = (ev as any).data ?? ev;
const dataObj =
typeof rawData === "string"
? (() => {
try {
return JSON.parse(rawData);
} catch {
return { raw: rawData };
}
})()
: rawData;
// Prefer work_order_id always
const workOrderId =
(ev as any)?.work_order_id ? String((ev as any).work_order_id)
: (ev as any)?.data?.work_order_id ? String((ev as any).data.work_order_id)
: null;
const sku =
(ev as any)?.sku ? String((ev as any).sku)
: (ev as any)?.data?.sku ? String((ev as any).data.sku)
: null;
// 7) Store event with Phase 0 meta
const row = await prisma.machineEvent.create({
data: {
orgId,
machineId: machine.id,
// Phase 0 meta
schemaVersion,
seq,
ts: tsDeviceDate,
topic: String((ev as any).topic ?? finalType),
eventType: finalType,
severity: sev,
requiresAck: !!(ev as any).requires_ack,
title,
description,
data: dataObj,
workOrderId,
sku,
},
});
// Optional: update machine last seen
await prisma.machine.update({
where: { id: machine.id },
data: {
schemaVersion,
seq,
tsDevice: tsDeviceDate,
tsServer: new Date(),
},
});
// 8) Ingest log success
await prisma.ingestLog.create({
data: {
orgId,
machineId: machine.id,
endpoint,
ok: true,
status: 200,
schemaVersion,
seq,
tsDevice: tsDeviceDate,
body: rawBody,
ip,
userAgent,
},
});
return NextResponse.json({
ok: true,
createdCount: 1,
created: [{ id: row.id, ts: row.ts, eventType: row.eventType }],
skippedCount: 0,
skipped: [],
});
} catch (err: any) {
const msg = err?.message ? String(err.message) : "Unknown error";
try {
await prisma.ingestLog.create({
data: {
orgId,
machineId,
endpoint,
ok: false,
status: 500,
errorCode: "SERVER_ERROR",
errorMsg: msg,
schemaVersion,
seq,
tsDevice: tsDeviceDate ?? undefined,
body: rawBody,
ip,
userAgent,
},
});
} catch {}
return NextResponse.json({ ok: false, error: "Server error", detail: msg }, { status: 500 });
}
}