pre-bemis
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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();
|
||||
|
||||
@@ -29,10 +31,63 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -158,25 +213,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
|
||||
|
||||
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
|
||||
const criticalSeverities = ["critical", "error", "high"];
|
||||
const eventWhere = {
|
||||
const eventWhereBase = {
|
||||
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,
|
||||
where: eventWhereBase,
|
||||
orderBy: { ts: "desc" },
|
||||
take: eventsOnly ? 300 : 120,
|
||||
select: {
|
||||
@@ -192,15 +237,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
|
||||
workOrderId: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.count({ where: eventWhere }),
|
||||
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 = normalized.filter((event) => {
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
@@ -249,25 +308,185 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ mach
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.machineCycle.deleteMany({
|
||||
where: {
|
||||
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,
|
||||
});
|
||||
|
||||
return tx.machine.deleteMany({
|
||||
where: {
|
||||
id: machineId,
|
||||
orgId: session.orgId,
|
||||
},
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
if (result.count === 0) {
|
||||
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||
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: true });
|
||||
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { cookies } from "next/headers";
|
||||
import { generatePairingCode } from "@/lib/pairingCode";
|
||||
import { z } from "zod";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
let machinesColdStart = true;
|
||||
|
||||
function getColdStartInfo() {
|
||||
const coldStart = machinesColdStart;
|
||||
machinesColdStart = false;
|
||||
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||
}
|
||||
|
||||
const createMachineSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
@@ -13,72 +27,66 @@ const createMachineSchema = z.object({
|
||||
location: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
async function requireSession() {
|
||||
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
|
||||
if (!sessionId) return null;
|
||||
export async function GET(req: Request) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||
const url = new URL(req.url);
|
||||
const includeKpi = url.searchParams.get("includeKpi") === "1";
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { org: true, user: true },
|
||||
});
|
||||
|
||||
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const authStart = nowMs();
|
||||
const session = await requireSession();
|
||||
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId: session.orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const preQueryStart = nowMs();
|
||||
const machinesStart = nowMs();
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
const machines = await fetchMachineBase(session.orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
let kpis: Awaited<ReturnType<typeof fetchLatestKpis>> = [];
|
||||
if (includeKpi) {
|
||||
const kpiStart = nowMs();
|
||||
kpis = await fetchLatestKpis(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
}
|
||||
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
// flatten latest heartbeat for UI convenience
|
||||
const out = machines.map((m) => ({
|
||||
...m,
|
||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
const out = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
includeKpi,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, machines: out });
|
||||
const payload = { ok: true, machines: out };
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.machines.api", {
|
||||
orgId: session.orgId,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
timings,
|
||||
counts: { machines: out.length },
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
|
||||
Reference in New Issue
Block a user