493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
import { Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { requireSession } from "@/lib/auth/requireSession";
|
|
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
|
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
|
|
|
|
const machineIdSchema = z.string().uuid();
|
|
|
|
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) {
|
|
if (value == null || value === "") return fallback;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
type MachineFkReference = {
|
|
tableName: string;
|
|
columnName: string;
|
|
deleteRule: string;
|
|
};
|
|
|
|
function quoteIdent(identifier: string) {
|
|
return `"${identifier.replace(/"/g, "\"\"")}"`;
|
|
}
|
|
|
|
async function cleanupMachineReferences(machineId: string) {
|
|
const refs = await prisma.$queryRaw<MachineFkReference[]>`
|
|
SELECT DISTINCT
|
|
tc.table_name AS "tableName",
|
|
kcu.column_name AS "columnName",
|
|
rc.delete_rule AS "deleteRule"
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.key_column_usage kcu
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
AND tc.table_schema = kcu.table_schema
|
|
JOIN information_schema.referential_constraints rc
|
|
ON tc.constraint_name = rc.constraint_name
|
|
AND tc.table_schema = rc.constraint_schema
|
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
AND tc.table_schema = 'public'
|
|
AND rc.unique_constraint_schema = 'public'
|
|
AND rc.unique_constraint_name IN (
|
|
SELECT constraint_name
|
|
FROM information_schema.table_constraints
|
|
WHERE table_schema = 'public'
|
|
AND table_name = 'Machine'
|
|
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
|
)
|
|
`;
|
|
|
|
for (const ref of refs) {
|
|
if (ref.tableName === "Machine") continue;
|
|
const table = quoteIdent(ref.tableName);
|
|
const column = quoteIdent(ref.columnName);
|
|
const rule = String(ref.deleteRule ?? "").toUpperCase();
|
|
|
|
if (rule === "CASCADE") continue;
|
|
|
|
if (rule === "SET NULL") {
|
|
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
|
|
continue;
|
|
}
|
|
|
|
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
|
|
}
|
|
}
|
|
|
|
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 { machineId } = await params;
|
|
if (!machineIdSchema.safeParse(machineId).success) {
|
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
|
}
|
|
|
|
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: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
|
}),
|
|
prisma.machineSettings.findUnique({
|
|
where: { machineId },
|
|
select: { overridesJson: true },
|
|
}),
|
|
]);
|
|
|
|
if (!machineRow) {
|
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
|
}
|
|
|
|
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 thresholds = {
|
|
stoppageMultiplier,
|
|
macroStoppageMultiplier,
|
|
};
|
|
|
|
const machine = {
|
|
...machineRow,
|
|
effectiveCycleTime: null,
|
|
latestHeartbeat: machineRow.heartbeats[0] ?? null,
|
|
latestKpi: machineRow.kpiSnapshots[0] ?? null,
|
|
heartbeats: undefined,
|
|
kpiSnapshots: undefined,
|
|
};
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
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 eventWhereBase = {
|
|
orgId: session.orgId,
|
|
machineId,
|
|
ts: { gte: eventWindowStart },
|
|
};
|
|
|
|
const [rawEvents, eventsCountAll] = await Promise.all([
|
|
prisma.machineEvent.findMany({
|
|
where: eventWhereBase,
|
|
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: eventWhereBase }),
|
|
]);
|
|
|
|
const normalized = rawEvents.map((row) =>
|
|
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
|
|
);
|
|
|
|
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
|
|
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
|
|
const filtered =
|
|
eventsMode === "critical"
|
|
? allowed.filter((event) => {
|
|
const severity = String(event.severity ?? "").toLowerCase();
|
|
return (
|
|
criticalEventTypes.has(event.eventType) ||
|
|
event.requiresAck === true ||
|
|
criticalSeverities.includes(severity)
|
|
);
|
|
})
|
|
: allowed;
|
|
|
|
const seen = new Set<string>();
|
|
const deduped = filtered.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,
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
select: { role: true },
|
|
});
|
|
|
|
if (!canManageMachines(membership?.role)) {
|
|
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
if (attempt === 0) {
|
|
// Revoke credentials first in a committed write so ingest auth fails immediately.
|
|
const revoked = await prisma.machine.updateMany({
|
|
where: {
|
|
id: machineId,
|
|
orgId: session.orgId,
|
|
},
|
|
data: {
|
|
apiKey: null,
|
|
},
|
|
});
|
|
|
|
if (revoked.count === 0) {
|
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
|
}
|
|
|
|
invalidateMachineAuth(machineId);
|
|
}
|
|
|
|
// Avoid long interactive transactions on very large history tables (P2028 timeout).
|
|
// This sequence is idempotent and safe to retry because apiKey is revoked first.
|
|
await prisma.machineCycle.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.machineHeartbeat.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.machineKpiSnapshot.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.machineEvent.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.machineWorkOrder.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.machineSettings.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.settingsAudit.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.alertNotification.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.machineFinancialOverride.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.reasonEntry.deleteMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
});
|
|
|
|
await prisma.downtimeAction.updateMany({
|
|
where: {
|
|
machineId,
|
|
},
|
|
data: {
|
|
machineId: null,
|
|
},
|
|
});
|
|
|
|
const result = await prisma.machine.deleteMany({
|
|
where: {
|
|
id: machineId,
|
|
orgId: session.orgId,
|
|
},
|
|
});
|
|
|
|
if (result.count === 0) {
|
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
|
}
|
|
|
|
invalidateMachineAuth(machineId);
|
|
return NextResponse.json({ ok: true });
|
|
} catch (err: unknown) {
|
|
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error("DELETE /api/machines/[machineId] failed", {
|
|
machineId,
|
|
orgId: session.orgId,
|
|
attempt,
|
|
code,
|
|
message,
|
|
});
|
|
|
|
if (code === "P2003") {
|
|
if (attempt < 2) {
|
|
try {
|
|
await cleanupMachineReferences(machineId);
|
|
} catch (cleanupErr: unknown) {
|
|
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
|
console.error("DELETE /api/machines/[machineId] cleanup failed", {
|
|
machineId,
|
|
orgId: session.orgId,
|
|
attempt,
|
|
cleanupMessage,
|
|
});
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
|
|
continue;
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
error: "Machine has dependent records and could not be removed",
|
|
code,
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
if (code === "P2022") {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
error: "Server schema is out of date for machine delete",
|
|
code,
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
if (code === "P2028") {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
error: "Delete timed out while removing machine history",
|
|
code,
|
|
},
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
|
|
if (code) {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
error: "Delete failed due to database error",
|
|
code,
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
|
|
}
|