// mis-control-tower/app/api/ingest/kpi/route.ts import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { normalizeSnapshotV1 } from "@/lib/contracts/v1"; import { toJsonValue } from "@/lib/prismaJson"; import { logLine } from "@/lib/logger"; 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; } function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as Record; } function toFiniteNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return null; } function toFiniteInt(value: unknown): number | null { const parsed = toFiniteNumber(value); if (parsed == null) return null; return Math.trunc(parsed); } function pickFirstNumber(...values: unknown[]) { for (const value of values) { const parsed = toFiniteNumber(value); if (parsed != null) return parsed; } return null; } function readPath(root: unknown, path: string[]): unknown { let current = root; for (const key of path) { const record = asRecord(current); if (!record) return undefined; current = record[key]; } return current; } function collectQualityTrace(params: { rawBody: unknown; normalizedKpis: Record | null; persistedQuality: number | null; machineId: string; rowId: string; }) { const { rawBody, normalizedKpis, persistedQuality, machineId, rowId } = params; const candidates = [ "kpis.quality", "payload.kpis.quality", "kpi_snapshot.quality", "quality", "payload.quality", ] as const; const rawQualityCandidates: Record = {}; for (const path of candidates) { const value = readPath(rawBody, path.split(".")); rawQualityCandidates[path] = { type: value === null ? "null" : typeof value, value, }; } const normalizedQuality = normalizedKpis?.quality; return { machineId, rowId, rawQualityCandidates, normalizedQuality: { type: normalizedQuality === null ? "null" : typeof normalizedQuality, value: normalizedQuality ?? null, }, persistedQuality: { type: persistedQuality === null ? "null" : typeof persistedQuality, value: persistedQuality, }, }; } export async function POST(req: Request) { const endpoint = "/api/ingest/kpi"; const startedAt = Date.now(); const ip = getClientIp(req); const userAgent = req.headers.get("user-agent"); const traceEnabled = process.env.TRACE_KPI_INGEST === "1" || req.headers.get("x-debug-ingest") === "1"; let rawBody: unknown = 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: toJsonValue(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 getMachineAuth(machineId, apiKey); if (!machine) { await prisma.ingestLog.create({ data: { endpoint, ok: false, status: 401, errorCode: "UNAUTHORIZED", errorMsg: "Unauthorized (machineId/apiKey mismatch)", body: toJsonValue(rawBody), machineId, schemaVersion, seq, tsDevice: tsDeviceDate, ip, userAgent, }, }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } orgId = machine.orgId; const woRecord = (body.activeWorkOrder ?? {}) as Record; const activeWorkOrderId = woRecord.id != null ? String(woRecord.id).trim() : ""; const activeSku = woRecord.sku != null ? String(woRecord.sku).trim() : ""; const activeStatus = woRecord.status != null ? String(woRecord.status).trim() : ""; const activeTargetQty = toFiniteInt(woRecord.target); const activeCycleTime = toFiniteNumber(woRecord.cycleTime); const good = pickFirstNumber(woRecord.good, woRecord.goodParts, woRecord.good_parts); const scrap = pickFirstNumber(woRecord.scrap, woRecord.scrapParts, woRecord.scrap_parts); const activeGoodParts = Math.max(0, Math.trunc(good ?? 0)); const activeScrapParts = Math.max(0, Math.trunc(scrap ?? 0)); const activeCycleCount = Math.max( 0, toFiniteInt(woRecord.cycleCount ?? woRecord.cycle_count ?? body.cycle_count) ?? 0 ); const snapshotCycleCount = toFiniteInt(body.cycle_count) ?? toFiniteInt(woRecord.cycle_count) ?? toFiniteInt(woRecord.cycleCount); const snapshotGoodParts = toFiniteInt(body.good_parts) ?? toFiniteInt(woRecord.good_parts) ?? toFiniteInt(woRecord.goodParts); const snapshotScrapParts = toFiniteInt(body.scrap_parts) ?? toFiniteInt(woRecord.scrap_parts) ?? toFiniteInt(woRecord.scrapParts); const k = body.kpis ?? {}; const safeCycleTime = typeof body.cycleTime === "number" && body.cycleTime > 0 ? body.cycleTime : activeCycleTime != null && activeCycleTime > 0 ? activeCycleTime : null; const safeCavities = typeof body.cavities === "number" && body.cavities > 0 ? body.cavities : typeof woRecord.cavities === "number" && woRecord.cavities > 0 ? woRecord.cavities : null; // Write snapshot (ts = tsDevice; tsServer auto). Idempotent on (org, machine, ts) to absorb retries. const kpiData = { orgId, machineId: machine.id, schemaVersion, seq, ts: tsDeviceDate, workOrderId: activeWorkOrderId || null, sku: activeSku || null, target: activeTargetQty, good: good != null ? Math.trunc(good) : null, scrap: scrap != null ? Math.trunc(scrap) : null, cycleCount: snapshotCycleCount, goodParts: snapshotGoodParts, scrapParts: snapshotScrapParts, cavities: safeCavities, cycleTime: safeCycleTime, actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : 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, }; const insertKpi = await prisma.machineKpiSnapshot.createMany({ data: [kpiData], skipDuplicates: true, }); const row = await prisma.machineKpiSnapshot.findFirst({ where: { orgId, machineId: machine.id, ts: tsDeviceDate }, orderBy: { tsServer: "asc" }, }); if (!row) { return NextResponse.json({ ok: false, error: "Server error", detail: "KPI snapshot row missing" }, { status: 500 }); } if (activeWorkOrderId) { await prisma.machineWorkOrder.upsert({ where: { machineId_workOrderId: { machineId: machine.id, workOrderId: activeWorkOrderId, }, }, create: { orgId: machine.orgId, machineId: machine.id, workOrderId: activeWorkOrderId, sku: activeSku || null, targetQty: activeTargetQty, cycleTime: activeCycleTime, status: activeStatus || "RUNNING", goodParts: activeGoodParts, scrapParts: activeScrapParts, cycleCount: activeCycleCount, }, update: { sku: activeSku || undefined, targetQty: activeTargetQty ?? undefined, cycleTime: activeCycleTime ?? undefined, status: activeStatus || undefined, goodParts: activeGoodParts, scrapParts: activeScrapParts, cycleCount: activeCycleCount, }, }); } // 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(), }, }); const trace = collectQualityTrace({ rawBody, normalizedKpis: asRecord(k), persistedQuality: row.quality ?? null, machineId: machine.id, rowId: row.id, }); if (traceEnabled) { logLine("ingest.kpi.trace", { endpoint, machineId: machine.id, orgId, schemaVersion, seq: seq != null ? seq.toString() : null, ip, userAgent, trace, rawBody: toJsonValue(rawBody), }); } return NextResponse.json({ ok: true, id: row.id, tsDevice: row.ts, tsServer: row.tsServer, duplicate: insertKpi.count === 0, trace: traceEnabled ? trace : undefined, }); } catch (err: unknown) { const msg = err instanceof Error ? 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: toJsonValue(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; } }