pre-bemis

This commit is contained in:
Marcelo
2026-04-22 05:04:19 +00:00
parent ac1a7900c8
commit 80d27f83b6
91 changed files with 11769 additions and 820 deletions

View File

@@ -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 });
}

View File

@@ -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) {