Before 404 fix nginx api route issue

This commit is contained in:
Marcelo
2026-01-22 05:48:22 +00:00
parent 511d80b629
commit ac1a7900c8
6 changed files with 429 additions and 320 deletions

View File

@@ -1,316 +1,273 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { normalizeEvent } from "@/lib/events/normalizeEvent";
const machineIdSchema = z.string().uuid();
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const ALLOWED_EVENT_TYPES = new Set([
"slow-cycle",
"microstop",
"macrostop",
"offline",
"error",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"alert-delivery-failed",
]);
function canManageMachines(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function parseNumber(value: string | null, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession();
if (!session) {
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 windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h
const { machineId } = await params;
const machineBase = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true, updatedAt: true },
});
if (!machineBase) {
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const [heartbeatAgg, kpiAgg, eventAgg, cycleAgg, orgSettingsAgg] = await Promise.all([
prisma.machineHeartbeat.aggregate({
where: { orgId: session.orgId, machineId },
_max: { tsServer: true },
}),
prisma.machineKpiSnapshot.aggregate({
where: { orgId: session.orgId, machineId },
_max: { tsServer: true },
}),
prisma.machineEvent.aggregate({
where: { orgId: session.orgId, machineId, ts: { gte: eventsWindowStart } },
_max: { tsServer: true },
}),
prisma.machineCycle.aggregate({
where: { orgId: session.orgId, machineId },
_max: { ts: true },
const url = new URL(req.url);
const windowSec = Math.max(0, parseNumber(url.searchParams.get("windowSec"), 3600));
const eventsWindowSec = Math.max(0, parseNumber(url.searchParams.get("eventsWindowSec"), 21600));
const eventsMode = url.searchParams.get("events") ?? "critical";
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
const [machineRow, orgSettings, machineSettings] = await Promise.all([
prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
},
},
}),
prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
}),
prisma.machineSettings.findUnique({
where: { machineId },
select: { overridesJson: true },
}),
]);
const toMs = (value?: Date | null) => (value ? value.getTime() : 0);
const lastModifiedMs = Math.max(
toMs(machineBase.updatedAt),
toMs(heartbeatAgg._max.tsServer),
toMs(kpiAgg._max.tsServer),
toMs(eventAgg._max.tsServer),
toMs(cycleAgg._max.ts),
toMs(orgSettingsAgg?.updatedAt)
);
const versionParts = [
session.orgId,
machineId,
eventsMode,
eventsOnly ? "1" : "0",
eventsWindowSec,
windowSec,
toMs(machineBase.updatedAt),
toMs(heartbeatAgg._max.tsServer),
toMs(kpiAgg._max.tsServer),
toMs(eventAgg._max.tsServer),
toMs(cycleAgg._max.ts),
toMs(orgSettingsAgg?.updatedAt),
];
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
const lastModified = new Date(lastModifiedMs || 0).toUTCString();
const responseHeaders = new Headers({
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
ETag: etag,
"Last-Modified": lastModified,
Vary: "Cookie",
});
const ifNoneMatch = _req.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
if (!machineRow) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
const ifModifiedSince = _req.headers.get("if-modified-since");
if (!ifNoneMatch && ifModifiedSince) {
const since = Date.parse(ifModifiedSince);
if (!Number.isNaN(since) && lastModifiedMs <= since) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
}
const overrides = isPlainObject(machineSettings?.overridesJson) ? machineSettings?.overridesJson : {};
const thresholdsOverride = isPlainObject(overrides.thresholds) ? overrides.thresholds : {};
const stoppageMultiplier =
typeof thresholdsOverride.stoppageMultiplier === "number"
? thresholdsOverride.stoppageMultiplier
: Number(orgSettings?.stoppageMultiplier ?? 1.5);
const macroStoppageMultiplier =
typeof thresholdsOverride.macroStoppageMultiplier === "number"
? thresholdsOverride.macroStoppageMultiplier
: Number(orgSettings?.macroStoppageMultiplier ?? 5);
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: {
id: true,
name: true,
code: true,
location: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
},
},
});
if (!machine) {
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
}
const microMultiplier = Number(orgSettingsAgg?.stoppageMultiplier ?? 1.5);
const macroMultiplier = Math.max(
microMultiplier,
Number(orgSettingsAgg?.macroStoppageMultiplier ?? 5)
);
const rawEvents = await prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
ts: { gte: eventsWindowStart },
},
orderBy: { ts: "desc" },
take: 100, // pull more, we'll filter after normalization
select: {
id: true,
ts: true,
topic: true,
eventType: true,
severity: true,
title: true,
description: true,
requiresAck: true,
data: true,
workOrderId: true,
},
});
const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier, macroMultiplier })
);
const ALLOWED_TYPES = new Set([
"slow-cycle",
"microstop",
"macrostop",
"offline",
"error",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"alert-delivery-failed",
]);
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 thresholds = {
stoppageMultiplier,
macroStoppageMultiplier,
};
const eventsFiltered = eventsMode === "critical" ? allEvents.filter(isCritical) : allEvents;
const events = eventsFiltered.slice(0, 30);
const eventsCountAll = allEvents.length;
const eventsCountCritical = allEvents.filter(isCritical).length;
const machine = {
...machineRow,
effectiveCycleTime: null,
latestHeartbeat: machineRow.heartbeats[0] ?? null,
latestKpi: machineRow.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
};
if (eventsOnly) {
return NextResponse.json(
{ ok: true, events, eventsCountAll, eventsCountCritical },
{ headers: responseHeaders }
);
}
const cycles = eventsOnly
? []
: await prisma.machineCycle.findMany({
where: {
orgId: session.orgId,
machineId,
ts: { gte: new Date(Date.now() - windowSec * 1000) },
},
orderBy: { ts: "asc" },
select: {
ts: true,
tsServer: true,
cycleCount: true,
actualCycleTime: true,
theoreticalCycleTime: true,
workOrderId: true,
sku: true,
},
});
// ---- cycles window ----
const latestKpi = machine.kpiSnapshots[0] ?? null;
// If KPI cycleTime missing, fallback to DB cycles (we fetch 1 first)
const latestCycleForIdeal = await prisma.machineCycle.findFirst({
where: { orgId: session.orgId, machineId },
orderBy: { ts: "desc" },
select: { theoreticalCycleTime: true },
});
const effectiveCycleTime =
latestKpi?.cycleTime ??
latestCycleForIdeal?.theoreticalCycleTime ??
null;
// Estimate how many cycles we need to cover the window.
// Add buffer so the chart doesnt look “tight”.
const estCycleSec = Math.max(1, Number(effectiveCycleTime ?? 14));
const needed = Math.ceil(windowSec / estCycleSec) + 50;
// Safety cap to avoid crazy payloads
const takeCycles = Math.min(1000, Math.max(200, needed));
const rawCycles = await prisma.machineCycle.findMany({
where: { orgId: session.orgId, machineId },
orderBy: { ts: "desc" },
take: takeCycles,
select: {
ts: true,
cycleCount: true,
actualCycleTime: true,
theoreticalCycleTime: true,
workOrderId: true,
sku: true,
},
});
const latestCycle = rawCycles[0] ?? null;
let activeStoppage: {
state: "microstop" | "macrostop";
startedAt: string;
durationSec: number;
theoreticalCycleTime: number;
} | null = null;
if (latestCycle?.ts && effectiveCycleTime && effectiveCycleTime > 0) {
const elapsedSec = (Date.now() - latestCycle.ts.getTime()) / 1000;
const microThresholdSec = effectiveCycleTime * microMultiplier;
const macroThresholdSec = effectiveCycleTime * macroMultiplier;
if (elapsedSec >= microThresholdSec) {
const isMacro = elapsedSec >= macroThresholdSec;
const state = isMacro ? "macrostop" : "microstop";
const thresholdSec = isMacro ? macroThresholdSec : microThresholdSec;
const startedAtMs = latestCycle.ts.getTime() + thresholdSec * 1000;
activeStoppage = {
state,
startedAt: new Date(startedAtMs).toISOString(),
durationSec: Math.max(0, Math.floor(elapsedSec - thresholdSec)),
theoreticalCycleTime: effectiveCycleTime,
const cyclesOut = cycles.map((row) => {
const ts = row.tsServer ?? row.ts;
return {
ts,
t: ts.getTime(),
cycleCount: row.cycleCount ?? null,
actual: row.actualCycleTime,
ideal: row.theoreticalCycleTime ?? null,
workOrderId: row.workOrderId ?? null,
sku: row.sku ?? null,
};
}
});
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
const criticalSeverities = ["critical", "error", "high"];
const eventWhere = {
orgId: session.orgId,
machineId,
ts: { gte: eventWindowStart },
eventType: { in: Array.from(ALLOWED_EVENT_TYPES) },
...(eventsMode === "critical"
? {
OR: [
{ eventType: "macrostop" },
{ requiresAck: true },
{ severity: { in: criticalSeverities } },
],
}
: {}),
};
const [rawEvents, eventsCountAll] = await Promise.all([
prisma.machineEvent.findMany({
where: eventWhere,
orderBy: { ts: "desc" },
take: eventsOnly ? 300 : 120,
select: {
id: true,
ts: true,
topic: true,
eventType: true,
severity: true,
title: true,
description: true,
requiresAck: true,
data: true,
workOrderId: true,
},
}),
prisma.machineEvent.count({ where: eventWhere }),
]);
const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
);
const seen = new Set<string>();
const deduped = normalized.filter((event) => {
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
deduped.sort((a, b) => {
const at = a.ts ? a.ts.getTime() : 0;
const bt = b.ts ? b.ts.getTime() : 0;
return bt - at;
});
return NextResponse.json({
ok: true,
machine,
events: deduped,
eventsCountAll,
cycles: cyclesOut,
thresholds,
activeStoppage: null,
});
}
// chart-friendly: oldest -> newest + numeric timestamps
const cycles = rawCycles
.slice()
.reverse()
.map((c) => ({
ts: c.ts,
t: c.ts.getTime(),
cycleCount: c.cycleCount ?? null,
actual: c.actualCycleTime,
ideal: c.theoreticalCycleTime ?? null,
workOrderId: c.workOrderId ?? null,
sku: c.sku ?? null,
}));
return NextResponse.json(
{
ok: true,
machine: {
id: machine.id,
name: machine.name,
code: machine.code,
location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null,
effectiveCycleTime,
export async function DELETE(_req: Request, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const membership = await prisma.orgUser.findUnique({
where: {
orgId_userId: {
orgId: session.orgId,
userId: session.userId,
},
thresholds: {
stoppageMultiplier: microMultiplier,
macroStoppageMultiplier: macroMultiplier,
},
activeStoppage,
events,
eventsCountAll,
eventsCountCritical,
cycles,
},
{ headers: responseHeaders }
);
select: { role: true },
});
if (!canManageMachines(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const result = await prisma.$transaction(async (tx) => {
await tx.machineCycle.deleteMany({
where: {
machineId,
orgId: session.orgId,
},
});
return tx.machine.deleteMany({
where: {
id: machineId,
orgId: session.orgId,
},
});
});
if (result.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
}