V1.1
This commit is contained in:
@@ -1,50 +1,217 @@
|
||||
// mis-control-tower/app/api/ingest/kpi/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeSnapshotV1 } 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;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = req.headers.get("x-api-key");
|
||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||
const endpoint = "/api/ingest/kpi";
|
||||
const startedAt = Date.now();
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.headers.get("user-agent");
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body?.machineId || !body?.kpis) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||
let rawBody: any = null;
|
||||
let orgId: string | null = null;
|
||||
let machineId: string | null = null;
|
||||
let seq: bigint | null = null;
|
||||
let schemaVersion: string | null = null;
|
||||
let tsDeviceDate: Date | null = null;
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
|
||||
rawBody = await req.json().catch(() => null);
|
||||
const normalized = normalizeSnapshotV1(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);
|
||||
|
||||
// Auth: machineId + apiKey must match
|
||||
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;
|
||||
|
||||
const wo = body.activeWorkOrder ?? {};
|
||||
const k = body.kpis ?? {};
|
||||
const safeCycleTime =
|
||||
typeof body.cycleTime === "number" && body.cycleTime > 0
|
||||
? body.cycleTime
|
||||
: (typeof (wo as any).cycleTime === "number" && (wo as any).cycleTime > 0 ? (wo as any).cycleTime : null);
|
||||
|
||||
const safeCavities =
|
||||
typeof body.cavities === "number" && body.cavities > 0
|
||||
? body.cavities
|
||||
: (typeof (wo as any).cavities === "number" && (wo as any).cavities > 0 ? (wo as any).cavities : null);
|
||||
// Write snapshot (ts = tsDevice; tsServer auto)
|
||||
const row = await prisma.machineKpiSnapshot.create({
|
||||
data: {
|
||||
orgId,
|
||||
machineId: machine.id,
|
||||
|
||||
// Phase 0 meta
|
||||
schemaVersion,
|
||||
seq,
|
||||
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
|
||||
|
||||
// Work order fields
|
||||
workOrderId: wo.id ? String(wo.id) : null,
|
||||
sku: wo.sku ? String(wo.sku) : null,
|
||||
target: typeof wo.target === "number" ? Math.trunc(wo.target) : null,
|
||||
good: typeof wo.good === "number" ? Math.trunc(wo.good) : null,
|
||||
scrap: typeof wo.scrap === "number" ? Math.trunc(wo.scrap) : null,
|
||||
|
||||
// Counters
|
||||
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
||||
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
||||
scrapParts: typeof body.scrap_parts === "number" ? body.scrap_parts : null,
|
||||
cavities: safeCavities,
|
||||
|
||||
// Cycle times
|
||||
cycleTime: safeCycleTime,
|
||||
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
|
||||
|
||||
// KPIs (0..100)
|
||||
availability: typeof k.availability === "number" ? k.availability : null,
|
||||
performance: typeof k.performance === "number" ? k.performance : null,
|
||||
quality: typeof k.quality === "number" ? k.quality : null,
|
||||
oee: typeof k.oee === "number" ? k.oee : null,
|
||||
|
||||
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
||||
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Optional but useful: update machine "last seen" meta fields
|
||||
await prisma.machine.update({
|
||||
where: { id: machine.id },
|
||||
data: {
|
||||
schemaVersion,
|
||||
seq,
|
||||
tsDevice: tsDeviceDate,
|
||||
tsServer: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
id: row.id,
|
||||
tsDevice: row.ts,
|
||||
tsServer: row.tsServer,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ? String(err.message) : "Unknown error";
|
||||
|
||||
// Never fail the request because logging failed
|
||||
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 });
|
||||
} finally {
|
||||
// (If later you add latency_ms to IngestLog, you can store Date.now() - startedAt here.)
|
||||
void startedAt;
|
||||
}
|
||||
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: String(body.machineId), apiKey },
|
||||
select: { id: true, orgId: true },
|
||||
});
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const wo = body.activeWorkOrder ?? {};
|
||||
const k = body.kpis ?? {};
|
||||
|
||||
const row = await prisma.machineKpiSnapshot.create({
|
||||
data: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
|
||||
workOrderId: wo.id ? String(wo.id) : null,
|
||||
sku: wo.sku ? String(wo.sku) : null,
|
||||
|
||||
target: typeof wo.target === "number" ? wo.target : null,
|
||||
good: typeof wo.good === "number" ? wo.good : null,
|
||||
scrap: typeof wo.scrap === "number" ? wo.scrap : null,
|
||||
|
||||
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
||||
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
||||
|
||||
cycleTime: typeof body.cycleTime === "number" ? body.cycleTime : null,
|
||||
|
||||
availability: typeof k.availability === "number" ? k.availability : null,
|
||||
performance: typeof k.performance === "number" ? k.performance : null,
|
||||
quality: typeof k.quality === "number" ? k.quality : null,
|
||||
oee: typeof k.oee === "number" ? k.oee : null,
|
||||
|
||||
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
||||
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user