Downtime catalog

This commit is contained in:
Marcelo
2026-05-06 00:36:48 +00:00
parent 0491237bad
commit bfc1673d89
42 changed files with 8035 additions and 1093 deletions

View File

@@ -13,7 +13,6 @@ function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
if (status === "data-loss") return t("recap.status.dataLoss");
if (status === "idle") return t("recap.status.idle");
return t("recap.status.offline");
}
@@ -112,7 +111,7 @@ export default function RecapGridClient({ initialData }: Props) {
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allStatuses")}</option>
{(["running", "mold-change", "stopped", "data-loss", "idle", "offline"] as const).map((status) => (
{(["running", "mold-change", "stopped", "idle", "offline"] as const).map((status) => (
<option key={status} value={status}>
{statusLabel(status, t)}
</option>

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertsConfig } from "@/components/settings/AlertsConfig";
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
import { ReasonCatalogConfig } from "@/components/settings/ReasonCatalogConfig";
import { useI18n } from "@/lib/i18n/useI18n";
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -122,6 +123,7 @@ const SETTINGS_TABS = [
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
{ id: "alerts", labelKey: "settings.tabs.alerts" },
{ id: "financial", labelKey: "settings.tabs.financial" },
{ id: "reasonCatalog", labelKey: "settings.tabs.reasonCatalog" },
{ id: "team", labelKey: "settings.tabs.team" },
] as const;
@@ -239,7 +241,6 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
const thresholds = asRecord(record.thresholds) ?? {};
const alerts = asRecord(record.alerts) ?? {};
const defaults = asRecord(record.defaults) ?? {};
return {
orgId: String(record.orgId ?? ""),
version: Number(record.version ?? 0),
@@ -1276,6 +1277,18 @@ export default function SettingsPage() {
</div>
)}
{activeTab === "reasonCatalog" && (
<div className="space-y-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-sm font-semibold text-white">{t("settings.reasonCatalog.title")}</div>
<p className="mt-1 text-xs text-zinc-400">{t("settings.reasonCatalog.subtitle")}</p>
<div className="mt-4">
<ReasonCatalogConfig disabled={saving} />
</div>
</div>
</div>
)}
{activeTab === "team" && (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -5,13 +5,14 @@ import { z } from "zod";
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
import { toJsonValue } from "@/lib/prismaJson";
import {
detailEffectiveReasonCode,
findCatalogReason,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
findCatalogReasonByReasonCode,
toReasonCode,
type ReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
const normalizeType = (t: unknown) =>
String(t ?? "")
@@ -169,7 +170,7 @@ function findCatalogReasonFlexible(
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: toReasonCode(category.id, detail.id),
reasonCode: detailEffectiveReasonCode(category, detail),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
@@ -177,12 +178,6 @@ function findCatalogReasonFlexible(
return null;
}
function getCatalogFromDefaults(defaultsJson: unknown) {
const defaults = asRecord(defaultsJson);
if (!defaults) return null;
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
}
function resolveReason(
raw: Record<string, unknown>,
kind: ReasonCatalogKind,
@@ -193,7 +188,13 @@ function resolveReason(
const reasonTextPath = parseReasonTextPath(raw.reasonText);
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
const fromCatalogFlexible = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
const rawReasonCodeEarly = clampText(raw.reasonCode, 64);
const fromCatalogByCode =
!fromCatalogFlexible && rawReasonCodeEarly
? findCatalogReasonByReasonCode(catalog, kind, rawReasonCodeEarly)
: null;
const fromCatalog = fromCatalogFlexible ?? fromCatalogByCode;
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
@@ -282,11 +283,13 @@ export async function POST(req: Request) {
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true, version: true },
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
const reasonCatalog = await effectiveReasonCatalogForOrg(
machine.orgId,
orgSettings?.defaultsJson ?? null,
orgSettings?.version ?? 1
);
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(

View File

@@ -258,14 +258,65 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
})
: 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;
// Build a lookup of raw event metadata (incidentKey, status, is_auto_ack)
// by event id, so we can collapse the normalized events down to one
// "active" + one "resolved" per incident.
const rawMetaById = new Map<string, { incidentKey: string | null; status: string | null; isAutoAck: boolean }>();
for (const row of rawEvents) {
let parsed: unknown = row.data;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
}
const data: Record<string, unknown> =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const isAutoAck =
data.is_auto_ack === true ||
data.isAutoAck === true ||
data.is_auto_ack === "true" ||
data.isAutoAck === "true";
const incidentKey =
typeof data.incidentKey === "string" ? data.incidentKey :
typeof data.incident_key === "string" ? data.incident_key : null;
const status = typeof data.status === "string" ? data.status.toLowerCase() : null;
rawMetaById.set(row.id, { incidentKey, status, isAutoAck });
}
// Drop pure auto-ack refresh pings.
const filteredNoAutoAck = filtered.filter((event) => {
const meta = rawMetaById.get(event.id);
return !meta?.isAutoAck;
});
// Group by incidentKey: keep at most one "active" (oldest = original happen)
// and one "resolved" (newest = actual end) per incident. Events without
// incidentKey pass through unchanged (mold-change, edge-case events).
const byGroup = new Map<string, typeof filteredNoAutoAck[number]>();
const passthrough: typeof filteredNoAutoAck = [];
for (const event of filteredNoAutoAck) {
const meta = rawMetaById.get(event.id);
const groupId = meta?.incidentKey;
if (!groupId) {
passthrough.push(event);
continue;
}
const statusKey = meta.status === "resolved" ? "resolved" : "active";
const key = `${groupId}:${statusKey}`;
const existing = byGroup.get(key);
if (!existing) {
byGroup.set(key, event);
continue;
}
const existingTs = existing.ts ? existing.ts.getTime() : 0;
const eventTs = event.ts ? event.ts.getTime() : 0;
const pickNewest = statusKey === "resolved";
const shouldReplace = pickNewest ? eventTs > existingTs : eventTs < existingTs;
if (shouldReplace) byGroup.set(key, event);
}
const deduped = [...passthrough, ...byGroup.values()];
deduped.sort((a, b) => {
const at = a.ts ? a.ts.getTime() : 0;
const bt = b.ts ? b.ts.getTime() : 0;

View File

@@ -0,0 +1,492 @@
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 });
}

View File

@@ -1,12 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
flattenReasonCatalog,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
import { flattenReasonCatalog, normalizeReasonCatalog, type ReasonCatalogKind } from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg, loadReasonCatalogFromDb } from "@/lib/reasonCatalogDb";
function asKind(value: string | null): ReasonCatalogKind | null {
const kind = String(value ?? "").toLowerCase();
@@ -26,20 +22,30 @@ export async function GET(req: Request) {
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { defaultsJson: true },
select: { defaultsJson: true, version: true },
});
const defaultsJson =
orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson)
? (orgSettings.defaultsJson as Record<string, unknown>)
const version = orgSettings?.version ?? 1;
const defaultsJson = orgSettings?.defaultsJson ?? null;
const fromDb = await loadReasonCatalogFromDb(session.orgId, version);
const catalog = await effectiveReasonCatalogForOrg(session.orgId, defaultsJson, version);
const defs =
defaultsJson && typeof defaultsJson === "object" && !Array.isArray(defaultsJson)
? (defaultsJson as Record<string, unknown>)
: {};
const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData);
const fallbackCatalog = await loadFallbackReasonCatalog();
const catalog = settingsCatalog ?? fallbackCatalog;
const rows = flattenReasonCatalog(catalog, kind);
const legacyJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
let source: "db" | "legacy" | "fallback";
if (fromDb) source = "db";
else if (legacyJson) source = "legacy";
else source = "fallback";
const rows = flattenReasonCatalog(catalog, kind, { activeOnly: true });
return NextResponse.json({
ok: true,
source: settingsCatalog ? "settings" : "fallback",
source,
kind,
catalogVersion: catalog.version,
categories: catalog[kind],

View File

@@ -17,7 +17,7 @@ import {
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -46,21 +46,18 @@ function pickAllowedOverrides(raw: unknown) {
return out;
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
async function attachReasonCatalog(
orgId: string,
defaultsJson: unknown,
settingsVersion: number,
base: Record<string, unknown>
): Promise<Record<string, unknown>> {
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
reasonCatalog: catalog,
reasonCatalogData: catalog,
reasonCatalogVersion: Number(catalog.version || 1),
};
}
@@ -164,9 +161,7 @@ export async function GET(
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const fallbackCatalog = await loadFallbackReasonCatalog();
const { settings, overrides } = await prisma.$transaction(async (tx) => {
const { orgRow, shifts, rawOverrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
@@ -175,25 +170,24 @@ export async function GET(
select: { overridesJson: true },
});
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
const effective = withReasonCatalog(
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
fallbackCatalog
);
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
return {
orgRow: orgSettings.settings,
shifts: orgSettings.shifts ?? [],
rawOverrides,
};
});
const baseOrg = buildSettingsPayload(orgRow, shifts) as Record<string, unknown>;
const orgPayload = await attachReasonCatalog(orgId as string, orgRow.defaultsJson, orgRow.version, baseOrg);
const effective = deepMerge(orgPayload, rawOverrides) as Record<string, unknown>;
return NextResponse.json({
ok: true,
machineId,
orgSettings: settings.org,
effectiveSettings: settings.effective,
overrides,
orgSettings: orgPayload,
effectiveSettings: effective,
overrides: rawOverrides,
});
}
@@ -413,25 +407,23 @@ export async function PUT(
},
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = withReasonCatalog(
deepMerge(orgPayload, overrides) as Record<string, unknown>,
fallbackCatalog
);
return {
orgPayload,
overrides,
effective,
orgSettingsRow: orgSettings.settings,
shifts: orgSettings.shifts ?? [],
overrides: pickAllowedOverrides(saved.overridesJson ?? {}),
overridesUpdatedAt: saved.updatedAt,
};
});
const baseOrg = buildSettingsPayload(result.orgSettingsRow, result.shifts) as Record<string, unknown>;
const orgPayload = await attachReasonCatalog(
session.orgId,
result.orgSettingsRow.defaultsJson,
result.orgSettingsRow.version,
baseOrg
);
const effective = deepMerge(orgPayload, result.overrides) as Record<string, unknown>;
const overridesUpdatedAt =
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
? result.overridesUpdatedAt.toISOString()
@@ -440,7 +432,7 @@ export async function PUT(
await publishSettingsUpdate({
orgId: session.orgId,
machineId,
version: Number(result.orgPayload.version ?? 0),
version: Number(result.orgSettingsRow.version ?? 0),
source,
overridesUpdatedAt,
});
@@ -451,8 +443,8 @@ export async function PUT(
return NextResponse.json({
ok: true,
machineId,
orgSettings: result.orgPayload,
effectiveSettings: result.effective,
orgSettings: orgPayload,
effectiveSettings: effective,
overrides: result.overrides,
});
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion, composeReasonCode } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
const patchSchema = z.object({
name: z.string().trim().min(1).max(200).optional(),
codePrefix: z
.string()
.trim()
.min(1)
.max(32)
.transform((s) => s.toUpperCase())
.optional(),
sortOrder: z.number().int().optional(),
active: z.boolean().optional(),
});
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ categoryId: string }> }
) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const { categoryId } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const existing = await prisma.reasonCatalogCategory.findFirst({
where: { id: categoryId, orgId: auth.session.orgId },
include: { items: true },
});
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const nextPrefix = parsed.data.codePrefix ?? existing.codePrefix;
if (parsed.data.codePrefix !== undefined && !PREFIX_RE.test(nextPrefix)) {
return NextResponse.json(
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
{ status: 400 }
);
}
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
const proposed = new Set<string>();
for (const it of existing.items) {
proposed.add(composeReasonCode(nextPrefix, it.codeSuffix));
}
const codes = [...proposed];
const conflicts = await prisma.reasonCatalogItem.findMany({
where: {
orgId: auth.session.orgId,
reasonCode: { in: codes },
NOT: { categoryId: existing.id },
},
select: { reasonCode: true },
});
if (conflicts.length) {
return NextResponse.json(
{ ok: false, error: "Prefix change would duplicate codes", conflicts: conflicts.map((c) => c.reasonCode) },
{ status: 409 }
);
}
}
try {
await prisma.$transaction(async (tx) => {
await tx.reasonCatalogCategory.update({
where: { id: categoryId },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.codePrefix !== undefined ? { codePrefix: parsed.data.codePrefix } : {}),
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
},
});
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
for (const it of existing.items) {
const reasonCode = composeReasonCode(nextPrefix, it.codeSuffix);
await tx.reasonCatalogItem.update({
where: { id: it.id },
data: { reasonCode },
});
}
}
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
});
const updated = await prisma.reasonCatalogCategory.findUnique({
where: { id: categoryId },
include: { items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] } },
});
return NextResponse.json({ ok: true, category: updated });
} catch (e) {
console.error("[reason-catalog category PATCH]", e);
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
const bodySchema = z.object({
kind: z.enum(["downtime", "scrap"]),
name: z.string().trim().min(1).max(200),
codePrefix: z
.string()
.trim()
.min(1)
.max(32)
.transform((s) => s.toUpperCase()),
});
export async function POST(req: Request) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const { kind, name, codePrefix } = parsed.data;
if (!PREFIX_RE.test(codePrefix)) {
return NextResponse.json(
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
{ status: 400 }
);
}
try {
const row = await prisma.$transaction(async (tx) => {
const last = await tx.reasonCatalogCategory.findFirst({
where: { orgId: auth.session.orgId, kind },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const sortOrder = (last?.sortOrder ?? -1) + 1;
const created = await tx.reasonCatalogCategory.create({
data: {
orgId: auth.session.orgId,
kind,
name,
codePrefix,
sortOrder,
active: true,
},
});
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
return created;
});
return NextResponse.json({ ok: true, category: row });
} catch (e) {
console.error("[reason-catalog categories POST]", e);
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const patchSchema = z.object({
name: z.string().trim().min(1).max(500).optional(),
codeSuffix: z.string().trim().min(1).max(32).optional(),
sortOrder: z.number().int().optional(),
active: z.boolean().optional(),
});
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ itemId: string }> }
) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const { itemId } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const existing = await prisma.reasonCatalogItem.findFirst({
where: { id: itemId, orgId: auth.session.orgId },
include: { category: true },
});
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const nextSuffix = parsed.data.codeSuffix ?? existing.codeSuffix;
if (parsed.data.codeSuffix !== undefined && !isNumericSuffix(nextSuffix)) {
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
}
const reasonCode = composeReasonCode(existing.category.codePrefix, nextSuffix);
if (reasonCode !== existing.reasonCode) {
const conflict = await prisma.reasonCatalogItem.findFirst({
where: { orgId: auth.session.orgId, reasonCode, NOT: { id: itemId } },
select: { id: true },
});
if (conflict) {
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
}
}
try {
await prisma.$transaction(async (tx) => {
await tx.reasonCatalogItem.update({
where: { id: itemId },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.codeSuffix !== undefined ? { codeSuffix: nextSuffix, reasonCode } : {}),
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
},
});
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
});
const updated = await prisma.reasonCatalogItem.findUnique({ where: { id: itemId } });
return NextResponse.json({ ok: true, item: updated });
} catch (e) {
console.error("[reason-catalog item PATCH]", e);
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const bodySchema = z.object({
categoryId: z.string().uuid(),
codeSuffix: z.string().trim().min(1).max(32),
name: z.string().trim().min(1).max(500),
sortOrder: z.number().int().optional(),
});
export async function POST(req: Request) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const { categoryId, codeSuffix, name, sortOrder } = parsed.data;
if (!isNumericSuffix(codeSuffix)) {
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
}
const category = await prisma.reasonCatalogCategory.findFirst({
where: { id: categoryId, orgId: auth.session.orgId },
});
if (!category) return NextResponse.json({ ok: false, error: "Category not found" }, { status: 404 });
const reasonCode = composeReasonCode(category.codePrefix, codeSuffix);
try {
const row = await prisma.$transaction(async (tx) => {
let nextOrder = sortOrder;
if (nextOrder === undefined) {
const last = await tx.reasonCatalogItem.findFirst({
where: { categoryId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
nextOrder = (last?.sortOrder ?? -1) + 1;
}
const created = await tx.reasonCatalogItem.create({
data: {
orgId: auth.session.orgId,
categoryId,
name,
codeSuffix,
reasonCode,
sortOrder: nextOrder,
active: true,
},
});
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
return created;
});
return NextResponse.json({ ok: true, item: row });
} catch (e: unknown) {
const code = typeof e === "object" && e && "code" in e ? (e as { code: string }).code : "";
if (code === "P2002") {
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
}
console.error("[reason-catalog items POST]", e);
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
/** Full tree for Control Tower (includes inactive rows). */
export async function GET() {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: auth.session.orgId },
select: { version: true },
});
const categories = await prisma.reasonCatalogCategory.findMany({
where: { orgId: auth.session.orgId },
include: {
items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] },
},
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
return NextResponse.json({
ok: true,
catalogVersion: orgSettings?.version ?? 1,
categories: categories.map((c) => ({
id: c.id,
kind: c.kind,
name: c.name,
codePrefix: c.codePrefix,
sortOrder: c.sortOrder,
active: c.active,
items: c.items.map((it) => ({
id: it.id,
name: it.name,
codeSuffix: it.codeSuffix,
reasonCode: it.reasonCode,
sortOrder: it.sortOrder,
active: it.active,
})),
})),
});
}

View File

@@ -19,7 +19,7 @@ import {
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -39,21 +39,18 @@ function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
async function attachReasonCatalog(
orgId: string,
defaultsJson: unknown,
settingsVersion: number,
base: Record<string, unknown>
): Promise<Record<string, unknown>> {
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
reasonCatalog: catalog,
reasonCatalogData: catalog,
reasonCatalogVersion: Number(catalog.version || 1),
};
}
@@ -66,7 +63,6 @@ const settingsPayloadSchema = z
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
reasonCatalog: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
@@ -145,8 +141,13 @@ async function loadSettingsPayload(orgId: string, userId: string) {
return found;
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
const base = buildSettingsPayload(loaded.settings, loaded.shifts ?? []) as Record<string, unknown>;
const payload = await attachReasonCatalog(
orgId,
loaded.settings.defaultsJson,
loaded.settings.version,
base
);
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
@@ -221,7 +222,6 @@ export async function PUT(req: Request) {
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const reasonCatalogRaw = parsed.data.reasonCatalog;
const expectedVersion = parsed.data.version;
const modules = parsed.data.modules;
@@ -233,7 +233,6 @@ export async function PUT(req: Request) {
thresholds === undefined &&
alerts === undefined &&
defaults === undefined &&
reasonCatalogRaw === undefined &&
modules === undefined
) {
@@ -252,13 +251,6 @@ export async function PUT(req: Request) {
if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
const nextReasonCatalog =
reasonCatalogRaw === undefined || reasonCatalogRaw === null
? reasonCatalogRaw
: normalizeReasonCatalog(reasonCatalogRaw);
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
}
if (modules !== undefined && !isPlainObject(modules)) {
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
}
@@ -333,20 +325,16 @@ export async function PUT(req: Request) {
: { ...currentModulesRaw, screenlessMode };
// Write defaultsJson if either defaults changed OR modules changed
const shouldWriteDefaultsJson =
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
const nextDefaultsJson = shouldWriteDefaultsJson
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
: undefined;
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
if (nextDefaultsJson) {
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
if (nextReasonCatalog === null) {
delete defaultsTarget.reasonCatalog;
} else if (nextReasonCatalog) {
defaultsTarget.reasonCatalog = nextReasonCatalog;
}
delete defaultsTarget.reasonCatalog;
delete defaultsTarget.reasonCatalogData;
}
@@ -444,12 +432,18 @@ export async function PUT(req: Request) {
return NextResponse.json({ ok: false, error: updated.error }, { status: 400 });
}
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
const baseOut = buildSettingsPayload(updated.settings, updated.shifts ?? []) as Record<string, unknown>;
const payload = await attachReasonCatalog(
session.orgId,
updated.settings.defaultsJson,
updated.settings.version,
baseOut
);
const updatedAt =
typeof payload.updatedAt === "string"
? payload.updatedAt
: payload.updatedAt
? payload.updatedAt.toISOString()
? (payload.updatedAt as Date).toISOString()
: undefined;
try {
await publishSettingsUpdate({