Macrostop and timeline segmentation

This commit is contained in:
mdares
2026-01-09 00:01:04 +00:00
parent 7790361a0a
commit d0ab254dd7
33 changed files with 1865 additions and 179 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
@@ -76,6 +76,11 @@ type MachineDetail = {
latestKpi: Kpi | null; latestKpi: Kpi | null;
}; };
type Thresholds = {
stoppageMultiplier: number;
macroStoppageMultiplier: number;
};
type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
type TimelineSeg = { type TimelineSeg = {
@@ -85,32 +90,148 @@ type TimelineSeg = {
state: TimelineState; state: TimelineState;
}; };
type UploadState = {
status: "idle" | "parsing" | "uploading" | "success" | "error";
message?: string;
count?: number;
};
type WorkOrderUpload = {
workOrderId: string;
sku?: string;
targetQty?: number;
cycleTime?: number;
};
const TOL = 0.10; const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
const NORMAL_TOL_SEC = 0.1;
function classifyGap(dtSec: number, idealSec: number): TimelineState {
const SLOW_X = 1.5;
const STOP_X = 3.0;
const MACRO_X = 10.0;
if (dtSec <= idealSec * SLOW_X) return "normal"; function resolveMultipliers(thresholds?: Thresholds | null) {
if (dtSec <= idealSec * STOP_X) return "slow"; const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT);
if (dtSec <= idealSec * MACRO_X) return "microstop"; const macro = Math.max(
micro,
Number(thresholds?.macroStoppageMultiplier ?? DEFAULT_MACRO_MULT)
);
return { micro, macro };
}
function classifyCycleDuration(
actualSec: number,
idealSec: number,
thresholds?: Thresholds | null
): TimelineState {
const { micro, macro } = resolveMultipliers(thresholds);
if (actualSec < idealSec + NORMAL_TOL_SEC) return "normal";
if (actualSec < idealSec * micro) return "slow";
if (actualSec < idealSec * macro) return "microstop";
return "macrostop"; return "macrostop";
} }
function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] {
if (!segs.length) return []; const WORK_ORDER_KEYS = {
const out: TimelineSeg[] = [segs[0]]; id: new Set(["workorderid", "workorder", "orderid", "woid", "work_order_id", "otid"]),
for (let i = 1; i < segs.length; i++) { sku: new Set(["sku"]),
const prev = out[out.length - 1]; cycle: new Set([
const cur = segs[i]; "theoreticalcycletimeseconds",
if (cur.state === prev.state && cur.start <= prev.end + 1) { "theoreticalcycletime",
prev.end = Math.max(prev.end, cur.end); "cycletime",
prev.durationSec = (prev.end - prev.start) / 1000; "cycle_time",
} else { "theoretical_cycle_time",
out.push(cur); ]),
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
};
function normalizeKey(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function parseCsvText(text: string) {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (ch === "\"") {
if (inQuotes && text[i + 1] === "\"") {
field += "\"";
i += 1;
} else {
inQuotes = !inQuotes;
}
continue;
} }
if (ch === "," && !inQuotes) {
row.push(field);
field = "";
continue;
}
if ((ch === "\n" || ch === "\r") && !inQuotes) {
if (ch === "\r" && text[i + 1] === "\n") i += 1;
row.push(field);
field = "";
if (row.some((cell) => cell.trim().length > 0)) {
rows.push(row);
}
row = [];
continue;
}
field += ch;
} }
row.push(field);
if (row.some((cell) => cell.trim().length > 0)) {
rows.push(row);
}
if (!rows.length) return [];
const headers = rows.shift()!.map((h) => h.trim());
return rows.map((cols) => {
const obj: Record<string, string> = {};
headers.forEach((header, idx) => {
obj[header] = (cols[idx] ?? "").trim();
});
return obj;
});
}
function pickRowValue(row: Record<string, any>, keys: Set<string>) {
for (const [key, value] of Object.entries(row)) {
if (keys.has(normalizeKey(key))) return value;
}
return undefined;
}
function rowsToWorkOrders(rows: Array<Record<string, any>>): WorkOrderUpload[] {
const seen = new Set<string>();
const out: WorkOrderUpload[] = [];
rows.forEach((row) => {
const rawId = pickRowValue(row, WORK_ORDER_KEYS.id);
const workOrderId = String(rawId ?? "").trim();
if (!workOrderId || seen.has(workOrderId)) return;
seen.add(workOrderId);
const sku = String(pickRowValue(row, WORK_ORDER_KEYS.sku) ?? "").trim();
const targetRaw = pickRowValue(row, WORK_ORDER_KEYS.target);
const cycleRaw = pickRowValue(row, WORK_ORDER_KEYS.cycle);
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime });
});
return out; return out;
} }
@@ -124,7 +245,10 @@ export default function MachineDetailClient() {
const [events, setEvents] = useState<EventRow[]>([]); const [events, setEvents] = useState<EventRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cycles, setCycles] = useState<CycleRow[]>([]); const [cycles, setCycles] = useState<CycleRow[]>([]);
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null); const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
const BUCKET = { const BUCKET = {
normal: { normal: {
@@ -183,6 +307,7 @@ export default function MachineDetailClient() {
setMachine(json.machine ?? null); setMachine(json.machine ?? null);
setEvents(json.events ?? []); setEvents(json.events ?? []);
setCycles(json.cycles ?? []); setCycles(json.cycles ?? []);
setThresholds(json.thresholds ?? null);
setError(null); setError(null);
setLoading(false); setLoading(false);
} catch { } catch {
@@ -200,6 +325,101 @@ export default function MachineDetailClient() {
}; };
}, [machineId, t]); }, [machineId, t]);
async function parseWorkOrdersFile(file: File) {
const name = file.name.toLowerCase();
if (name.endsWith(".csv")) {
const text = await file.text();
return rowsToWorkOrders(parseCsvText(text));
}
if (name.endsWith(".xls") || name.endsWith(".xlsx")) {
const buffer = await file.arrayBuffer();
const xlsx = await import("xlsx");
const workbook = xlsx.read(buffer, { type: "array" });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
if (!sheet) return [];
const rows = xlsx.utils.sheet_to_json(sheet, { defval: "" });
return rowsToWorkOrders(rows as Array<Record<string, any>>);
}
return null;
}
async function handleWorkOrderUpload(event: any) {
const file = event?.target?.files?.[0] as File | undefined;
if (!file) return;
if (!machineId) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadError") });
event.target.value = "";
return;
}
setUploadState({ status: "parsing", message: t("machine.detail.workOrders.uploadParsing") });
try {
const workOrders = await parseWorkOrdersFile(file);
if (!workOrders) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadInvalid") });
event.target.value = "";
return;
}
if (!workOrders.length) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadInvalid") });
event.target.value = "";
return;
}
setUploadState({ status: "uploading", message: t("machine.detail.workOrders.uploading") });
const res = await fetch("/api/work-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ machineId, workOrders }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json?.ok === false) {
if (res.status === 401 || res.status === 403) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadUnauthorized") });
} else {
setUploadState({
status: "error",
message: json?.error ?? t("machine.detail.workOrders.uploadError"),
});
}
event.target.value = "";
return;
}
setUploadState({
status: "success",
message: t("machine.detail.workOrders.uploadSuccess", { count: workOrders.length }),
count: workOrders.length,
});
event.target.value = "";
} catch {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadError") });
event.target.value = "";
}
}
const uploadButtonLabel =
uploadState.status === "parsing"
? t("machine.detail.workOrders.uploadParsing")
: uploadState.status === "uploading"
? t("machine.detail.workOrders.uploading")
: t("machine.detail.workOrders.upload");
const uploadStatusClass =
uploadState.status === "success"
? "bg-emerald-500/15 text-emerald-300 border-emerald-500/20"
: uploadState.status === "error"
? "bg-red-500/15 text-red-300 border-red-500/20"
: "bg-white/10 text-zinc-200 border-white/10";
const isUploading = uploadState.status === "parsing" || uploadState.status === "uploading";
function fmtPct(v?: number | null) { function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return t("common.na"); if (v === null || v === undefined || Number.isNaN(v)) return t("common.na");
return `${v.toFixed(1)}%`; return `${v.toFixed(1)}%`;
@@ -474,6 +694,7 @@ export default function MachineDetailClient() {
const cycleDerived = useMemo(() => { const cycleDerived = useMemo(() => {
const rows = cycles ?? []; const rows = cycles ?? [];
const { micro, macro } = resolveMultipliers(thresholds);
const mapped: CycleDerivedRow[] = rows.map((cycle) => { const mapped: CycleDerivedRow[] = rows.map((cycle) => {
const ideal = cycle.ideal ?? null; const ideal = cycle.ideal ?? null;
@@ -482,10 +703,7 @@ export default function MachineDetailClient() {
let bucket: CycleDerivedRow["bucket"] = "unknown"; let bucket: CycleDerivedRow["bucket"] = "unknown";
if (ideal != null && actual != null) { if (ideal != null && actual != null) {
if (actual <= ideal * (1 + TOL)) bucket = "normal"; bucket = classifyCycleDuration(actual, ideal, thresholds);
else if (extra != null && extra <= 1) bucket = "slow";
else if (extra != null && extra <= 10) bucket = "microstop";
else bucket = "macrostop";
} }
return { ...cycle, ideal, actual, extra, bucket }; return { ...cycle, ideal, actual, extra, bucket };
@@ -505,7 +723,7 @@ export default function MachineDetailClient() {
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null; const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
return { mapped, counts, avgDeltaPct }; return { mapped, counts, avgDeltaPct };
}, [cycles]); }, [cycles, thresholds]);
const deviationSeries = useMemo(() => { const deviationSeries = useMemo(() => {
const last = cycleDerived.mapped.slice(-100); const last = cycleDerived.mapped.slice(-100);
@@ -557,7 +775,7 @@ export default function MachineDetailClient() {
const timeline = useMemo(() => { const timeline = useMemo(() => {
const rows = cycles ?? []; const rows = cycles ?? [];
if (rows.length < 2) { if (rows.length < 1) {
return { return {
windowSec: 10800, windowSec: 10800,
segments: [] as TimelineSeg[], segments: [] as TimelineSeg[],
@@ -570,27 +788,24 @@ export default function MachineDetailClient() {
const end = rows[rows.length - 1].t; const end = rows[rows.length - 1].t;
const start = end - windowSec * 1000; const start = end - windowSec * 1000;
const idxFirst = Math.max(
0,
rows.findIndex((row) => row.t >= start) - 1
);
const sliced = rows.slice(idxFirst);
const segs: TimelineSeg[] = []; const segs: TimelineSeg[] = [];
for (let i = 1; i < sliced.length; i++) { for (const cycle of rows) {
const prev = sliced[i - 1]; const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
const cur = sliced[i]; const actual = cycle.actual ?? 0;
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
const segStart = Math.max(prev.t, start); const cycleEnd = cycle.t;
const segEnd = Math.min(cur.t, end); const cycleStart = cycleEnd - actual * 1000;
if (cycleEnd <= start || cycleStart >= end) continue;
const segStart = Math.max(cycleStart, start);
const segEnd = Math.min(cycleEnd, end);
if (segEnd <= segStart) continue; if (segEnd <= segStart) continue;
const dtSec = (cur.t - prev.t) / 1000; const state = classifyCycleDuration(actual, ideal, thresholds);
const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number;
if (!ideal || ideal <= 0) continue;
const state = classifyGap(dtSec, ideal);
segs.push({ segs.push({
start: segStart, start: segStart,
@@ -600,9 +815,8 @@ export default function MachineDetailClient() {
}); });
} }
const segments = mergeAdjacent(segs); return { windowSec, segments: segs, start, end };
return { windowSec, segments, start, end }; }, [cycles, cycleTarget, thresholds]);
}, [cycles, cycleTarget]);
const cycleTargetLabel = cycleTarget ? `${cycleTarget}s` : t("common.na"); const cycleTargetLabel = cycleTarget ? `${cycleTarget}s` : t("common.na");
const workOrderLabel = kpi?.workOrderId ?? t("common.na"); const workOrderLabel = kpi?.workOrderId ?? t("common.na");
@@ -625,13 +839,38 @@ export default function MachineDetailClient() {
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 flex-col items-end gap-2">
<Link <div className="flex items-center gap-2">
href="/machines" <input
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" ref={fileInputRef}
> type="file"
{t("machine.detail.back")} accept=".csv,.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
</Link> className="hidden"
onChange={handleWorkOrderUpload}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{uploadButtonLabel}
</button>
<Link
href="/machines"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
{t("machine.detail.back")}
</Link>
</div>
<div className="text-right text-[11px] text-zinc-500">
{t("machine.detail.workOrders.uploadHint")}
</div>
{uploadState.status !== "idle" && uploadState.message && (
<div className={`rounded-full border px-3 py-1 text-xs ${uploadStatusClass}`}>
{uploadState.message}
</div>
)}
</div> </div>
</div> </div>

View File

@@ -297,8 +297,21 @@ export default function MachinesPage() {
</div> </div>
<div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div> <div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
<div className="text-xl font-semibold text-white"> <div className="mt-1 flex items-center gap-2 text-sm font-semibold text-white">
{offline ? t("machines.status.noHeartbeat") : (hb?.message ?? t("machines.status.ok"))} {offline ? (
<>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-zinc-500" aria-hidden="true" />
<span>{t("machines.status.noHeartbeat")}</span>
</>
) : (
<>
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
</span>
<span>{t("machines.status.ok")}</span>
</>
)}
</div> </div>
</Link> </Link>
); );
@@ -310,4 +323,3 @@ export default function MachinesPage() {

View File

@@ -35,6 +35,11 @@ type MachineRow = {
latestKpi?: Kpi | null; latestKpi?: Kpi | null;
}; };
type Thresholds = {
stoppageMultiplier: number;
macroStoppageMultiplier: number;
};
type EventRow = { type EventRow = {
id: string; id: string;
ts: string; ts: string;
@@ -60,7 +65,17 @@ type CycleRow = {
const OFFLINE_MS = 30000; const OFFLINE_MS = 30000;
const EVENT_WINDOW_SEC = 1800; const EVENT_WINDOW_SEC = 1800;
const MAX_EVENT_MACHINES = 6; const MAX_EVENT_MACHINES = 6;
const TOL = 0.10; const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
function resolveMultipliers(thresholds?: Thresholds | null) {
const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT);
const macro = Math.max(
micro,
Number(thresholds?.macroStoppageMultiplier ?? DEFAULT_MACRO_MULT)
);
return { micro, macro };
}
function secondsAgo(ts: string | undefined, locale: string, fallback: string) { function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback; if (!ts) return fallback;
@@ -106,16 +121,17 @@ function sourceClass(src: EventRow["source"]) {
: "bg-emerald-500/15 text-emerald-300"; : "bg-emerald-500/15 text-emerald-300";
} }
function classifyDerivedEvent(c: CycleRow) { function classifyDerivedEvent(c: CycleRow, thresholds?: Thresholds | null) {
if (c.ideal == null || c.ideal <= 0 || c.actual <= 0) return null; if (c.ideal == null || c.ideal <= 0 || c.actual <= 0) return null;
if (c.actual <= c.ideal * (1 + TOL)) return null; if (c.actual <= c.ideal) return null;
const { micro, macro } = resolveMultipliers(thresholds);
const extra = c.actual - c.ideal; const extra = c.actual - c.ideal;
let eventType = "slow-cycle"; let eventType = "slow-cycle";
let severity = "warning"; let severity = "warning";
if (extra <= 1) { if (c.actual < c.ideal * micro) {
eventType = "slow-cycle"; eventType = "slow-cycle";
severity = "info"; severity = "warning";
} else if (extra <= 10) { } else if (c.actual < c.ideal * macro) {
eventType = "microstop"; eventType = "microstop";
severity = "warning"; severity = "warning";
} else { } else {
@@ -216,7 +232,7 @@ export default function OverviewPage() {
const cycles: CycleRow[] = Array.isArray(payload?.cycles) ? payload.cycles : []; const cycles: CycleRow[] = Array.isArray(payload?.cycles) ? payload.cycles : [];
for (const c of cycles.slice(-120)) { for (const c of cycles.slice(-120)) {
const derived = classifyDerivedEvent(c); const derived = classifyDerivedEvent(c, payload?.thresholds);
if (!derived) continue; if (!derived) continue;
combined.push({ combined.push({
id: `derived-${machine.id}-${c.t}`, id: `derived-${machine.id}-${c.t}`,

View File

@@ -21,6 +21,7 @@ type SettingsPayload = {
}; };
thresholds: { thresholds: {
stoppageMultiplier: number; stoppageMultiplier: number;
macroStoppageMultiplier: number;
oeeAlertThresholdPct: number; oeeAlertThresholdPct: number;
performanceThresholdPct: number; performanceThresholdPct: number;
qualitySpikeDeltaPct: number; qualitySpikeDeltaPct: number;
@@ -82,6 +83,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
thresholds: { thresholds: {
stoppageMultiplier: 1.5, stoppageMultiplier: 1.5,
oeeAlertThresholdPct: 90, oeeAlertThresholdPct: 90,
macroStoppageMultiplier: 5,
performanceThresholdPct: 85, performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5, qualitySpikeDeltaPct: 5,
}, },
@@ -151,6 +153,9 @@ function normalizeSettings(raw: any, fallbackName: (index: number) => string): S
stoppageMultiplier: Number( stoppageMultiplier: Number(
raw.thresholds?.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier raw.thresholds?.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier
), ),
macroStoppageMultiplier: Number(
raw.thresholds?.macroStoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.macroStoppageMultiplier
),
oeeAlertThresholdPct: Number( oeeAlertThresholdPct: Number(
raw.thresholds?.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct raw.thresholds?.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct
), ),
@@ -351,6 +356,7 @@ export default function SettingsPage() {
( (
key: key:
| "stoppageMultiplier" | "stoppageMultiplier"
| "macroStoppageMultiplier"
| "oeeAlertThresholdPct" | "oeeAlertThresholdPct"
| "performanceThresholdPct" | "performanceThresholdPct"
| "qualitySpikeDeltaPct", | "qualitySpikeDeltaPct",
@@ -651,6 +657,20 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("settings.thresholds.macroStoppage")}
<input
type="number"
min={1.1}
max={20}
step={0.1}
value={draft.thresholds.macroStoppageMultiplier}
onChange={(event) =>
updateThreshold("macroStoppageMultiplier", Number(event.target.value))
}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("settings.thresholds.performance")} (%) {t("settings.thresholds.performance")} (%)
<input <input
type="number" type="number"

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { z } from "zod";
function unwrapEnvelope(raw: any) { function unwrapEnvelope(raw: any) {
if (!raw || typeof raw !== "object") return raw; if (!raw || typeof raw !== "object") return raw;
@@ -25,6 +26,39 @@ function unwrapEnvelope(raw: any) {
}; };
} }
const numberFromAny = z.preprocess((value) => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") return Number(value);
return value;
}, z.number().finite());
const intFromAny = z.preprocess((value) => {
if (typeof value === "number") return Math.trunc(value);
if (typeof value === "string" && value.trim() !== "") return Math.trunc(Number(value));
return value;
}, z.number().int().finite());
const cyclePayloadSchema = z
.object({
machineId: z.string().uuid(),
cycle: z
.object({
actual_cycle_time: numberFromAny,
theoretical_cycle_time: numberFromAny.optional(),
cycle_count: intFromAny.optional(),
work_order_id: z.string().trim().max(64).optional(),
sku: z.string().trim().max(64).optional(),
cavities: intFromAny.optional(),
good_delta: intFromAny.optional(),
scrap_delta: intFromAny.optional(),
timestamp: numberFromAny.optional(),
ts: numberFromAny.optional(),
event_timestamp: numberFromAny.optional(),
})
.passthrough(),
})
.passthrough();
export async function POST(req: Request) { export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key"); const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 }); if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
@@ -32,24 +66,26 @@ export async function POST(req: Request) {
let body = await req.json().catch(() => null); let body = await req.json().catch(() => null);
body = unwrapEnvelope(body); body = unwrapEnvelope(body);
if (!body?.machineId || !body?.cycle) { const parsed = cyclePayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
} }
const machine = await prisma.machine.findFirst({ const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey }, where: { id: parsed.data.machineId, apiKey },
select: { id: true, orgId: true }, select: { id: true, orgId: true },
}); });
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const c = body.cycle; const c = parsed.data.cycle;
const raw = body as any;
const tsMs = const tsMs =
(typeof c.timestamp === "number" && c.timestamp) || (typeof c.timestamp === "number" && c.timestamp) ||
(typeof c.ts === "number" && c.ts) || (typeof c.ts === "number" && c.ts) ||
(typeof c.event_timestamp === "number" && c.event_timestamp) || (typeof c.event_timestamp === "number" && c.event_timestamp) ||
(typeof body.tsMs === "number" && body.tsMs) || (typeof raw?.tsMs === "number" && raw.tsMs) ||
(typeof body.tsDevice === "number" && body.tsDevice) || (typeof raw?.tsDevice === "number" && raw.tsDevice) ||
undefined; undefined;
const ts = tsMs ? new Date(tsMs) : new Date(); const ts = tsMs ? new Date(tsMs) : new Date();
@@ -60,8 +96,8 @@ export async function POST(req: Request) {
machineId: machine.id, machineId: machine.id,
ts, ts,
cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null, cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null,
actualCycleTime: Number(c.actual_cycle_time), actualCycleTime: c.actual_cycle_time,
theoreticalCycleTime: c.theoretical_cycle_time != null ? Number(c.theoretical_cycle_time) : null, theoreticalCycleTime: typeof c.theoretical_cycle_time === "number" ? c.theoretical_cycle_time : null,
workOrderId: c.work_order_id ? String(c.work_order_id) : null, workOrderId: c.work_order_id ? String(c.work_order_id) : null,
sku: c.sku ? String(c.sku) : null, sku: c.sku ? String(c.sku) : null,
cavities: typeof c.cavities === "number" ? c.cavities : null, cavities: typeof c.cavities === "number" ? c.cavities : null,

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { z } from "zod";
const normalizeType = (t: any) => const normalizeType = (t: any) =>
String(t ?? "") String(t ?? "")
@@ -33,9 +34,19 @@ const ALLOWED_TYPES = new Set([
"predictive-oee-decline", "predictive-oee-decline",
]); ]);
// thresholds for stop classification (tune later / move to machine config) const machineIdSchema = z.string().uuid();
const MICROSTOP_SEC = 60; const MAX_EVENTS = 100;
const MACROSTOP_SEC = 300;
//when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300;
function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
if (!text) return null;
return text.length > maxLen ? text.slice(0, maxLen) : text;
}
export async function POST(req: Request) { export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key"); const apiKey = req.headers.get("x-api-key");
@@ -68,14 +79,32 @@ export async function POST(req: Request) {
); );
} }
if (!machineIdSchema.safeParse(String(machineId)).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({ const machine = await prisma.machine.findFirst({
where: { id: String(machineId), apiKey }, where: { id: String(machineId), apiKey },
select: { id: true, orgId: true }, select: { id: true, orgId: true },
}); });
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
});
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(
defaultMicroMultiplier,
Number(orgSettings?.macroStoppageMultiplier ?? 5)
);
// ✅ normalize to array no matter what // ✅ normalize to array no matter what
const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent]; const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent];
if (events.length > MAX_EVENTS) {
return NextResponse.json({ ok: false, error: "Too many events" }, { status: 400 });
}
const created: { id: string; ts: Date; eventType: string }[] = []; const created: { id: string; ts: Date; eventType: string }[] = [];
const skipped: any[] = []; const skipped: any[] = [];
@@ -112,7 +141,29 @@ export async function POST(req: Request) {
null; null;
if (stopSec != null) { if (stopSec != null) {
finalType = stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop"; const theoretical =
Number(
(ev as any)?.data?.theoretical_cycle_time ??
(ev as any)?.data?.theoreticalCycleTime ??
0
) || 0;
const microMultiplier = Number(
(ev as any)?.data?.micro_threshold_multiplier ??
(ev as any)?.data?.threshold_multiplier ??
defaultMicroMultiplier
);
const macroMultiplier = Math.max(
microMultiplier,
Number((ev as any)?.data?.macro_threshold_multiplier ?? defaultMacroMultiplier)
);
if (theoretical > 0) {
const macroThresholdSec = theoretical * macroMultiplier;
finalType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
} else {
finalType = stopSec >= DEFAULT_MACROSTOP_SEC ? "macrostop" : "microstop";
}
} else { } else {
// missing duration -> conservative // missing duration -> conservative
finalType = "microstop"; finalType = "microstop";
@@ -125,13 +176,13 @@ export async function POST(req: Request) {
} }
const title = const title =
String((ev as any).title ?? "").trim() || clampText((ev as any).title, 160) ||
(finalType === "slow-cycle" ? "Slow Cycle Detected" : (finalType === "slow-cycle" ? "Slow Cycle Detected" :
finalType === "macrostop" ? "Macrostop Detected" : finalType === "macrostop" ? "Macrostop Detected" :
finalType === "microstop" ? "Microstop Detected" : finalType === "microstop" ? "Microstop Detected" :
"Event"); "Event");
const description = (ev as any).description ? String((ev as any).description) : null; const description = clampText((ev as any).description, 1000);
// store full blob, ensure object // store full blob, ensure object
const rawData = (ev as any).data ?? ev; const rawData = (ev as any).data ?? ev;
@@ -144,7 +195,7 @@ export async function POST(req: Request) {
orgId: machine.orgId, orgId: machine.orgId,
machineId: machine.id, machineId: machine.id,
ts, ts,
topic: String((ev as any).topic ?? finalType), topic: clampText((ev as any).topic ?? finalType, 64) ?? finalType,
eventType: finalType, eventType: finalType,
severity: sev, severity: sev,
requiresAck: !!(ev as any).requires_ack, requiresAck: !!(ev as any).requires_ack,
@@ -152,13 +203,13 @@ export async function POST(req: Request) {
description, description,
data: dataObj, data: dataObj,
workOrderId: workOrderId:
(ev as any)?.work_order_id ? String((ev as any).work_order_id) clampText((ev as any)?.work_order_id, 64) ??
: (ev as any)?.data?.work_order_id ? String((ev as any).data.work_order_id) clampText((ev as any)?.data?.work_order_id, 64) ??
: null, null,
sku: sku:
(ev as any)?.sku ? String((ev as any).sku) clampText((ev as any)?.sku, 64) ??
: (ev as any)?.data?.sku ? String((ev as any).data.sku) clampText((ev as any)?.data?.sku, 64) ??
: null, null,
}, },
}); });

View File

@@ -3,6 +3,13 @@ import type { NextRequest } from "next/server";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie"; import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
import { z } from "zod";
const tokenSchema = z.string().regex(/^[a-f0-9]{48}$/i);
const acceptSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
password: z.string().min(8).max(256),
});
async function loadInvite(token: string) { async function loadInvite(token: string) {
return prisma.orgInvite.findFirst({ return prisma.orgInvite.findFirst({
@@ -23,6 +30,9 @@ export async function GET(
{ params }: { params: Promise<{ token: string }> } { params }: { params: Promise<{ token: string }> }
) { ) {
const { token } = await params; const { token } = await params;
if (!tokenSchema.safeParse(token).success) {
return NextResponse.json({ ok: false, error: "Invalid invite token" }, { status: 400 });
}
const invite = await loadInvite(token); const invite = await loadInvite(token);
if (!invite) { if (!invite) {
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 }); return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
@@ -44,23 +54,26 @@ export async function POST(
{ params }: { params: Promise<{ token: string }> } { params }: { params: Promise<{ token: string }> }
) { ) {
const { token } = await params; const { token } = await params;
if (!tokenSchema.safeParse(token).success) {
return NextResponse.json({ ok: false, error: "Invalid invite token" }, { status: 400 });
}
const invite = await loadInvite(token); const invite = await loadInvite(token);
if (!invite) { if (!invite) {
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 }); return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
} }
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const name = String(body.name || "").trim(); const parsed = acceptSchema.safeParse(body);
const password = String(body.password || ""); if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid invite payload" }, { status: 400 });
}
const name = String(parsed.data.name || "").trim();
const password = parsed.data.password;
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
where: { email: invite.email }, where: { email: invite.email },
}); });
if (!password || password.length < 8) {
return NextResponse.json({ ok: false, error: "Password must be at least 8 characters" }, { status: 400 });
}
if (!existingUser && !name) { if (!existingUser && !name) {
return NextResponse.json({ ok: false, error: "Name is required" }, { status: 400 }); return NextResponse.json({ ok: false, error: "Name is required" }, { status: 400 });
} }

View File

@@ -1,19 +1,33 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { z } from "zod";
const COOKIE_NAME = "mis_session"; const COOKIE_NAME = "mis_session";
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
const loginSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
password: z.string().min(1).max(256),
next: z.string().optional(),
});
function safeNextPath(value: unknown) {
const raw = String(value ?? "").trim();
if (!raw) return "/machines";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/machines";
return raw;
}
export async function POST(req: Request) { export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase(); const parsed = loginSchema.safeParse(body);
const password = String(body.password || ""); if (!parsed.success) {
const next = String(body.next || "/machines"); return NextResponse.json({ ok: false, error: "Invalid login payload" }, { status: 400 });
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
} }
const email = parsed.data.email.toLowerCase();
const password = parsed.data.password;
const next = safeNextPath(parsed.data.next);
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) { if (!user || !user.isActive) {

View File

@@ -2,16 +2,30 @@ import { NextResponse } from "next/server";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie"; import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
password: z.string().min(1).max(256),
next: z.string().optional(),
});
function safeNextPath(value: unknown) {
const raw = String(value ?? "").trim();
if (!raw) return "/machines";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/machines";
return raw;
}
export async function POST(req: Request) { export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase(); const parsed = loginSchema.safeParse(body);
const password = String(body.password || ""); if (!parsed.success) {
const next = String(body.next || "/machines"); return NextResponse.json({ ok: false, error: "Invalid login payload" }, { status: 400 });
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
} }
const email = parsed.data.email.toLowerCase();
const password = parsed.data.password;
const next = safeNextPath(parsed.data.next);
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) { if (!user || !user.isActive) {

View File

@@ -3,7 +3,10 @@ import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
function normalizeEvent(row: any) { function normalizeEvent(
row: any,
thresholds: { microMultiplier: number; macroMultiplier: number }
) {
// ----------------------------- // -----------------------------
// 1) Parse row.data safely // 1) Parse row.data safely
// data may be: // data may be:
@@ -91,9 +94,24 @@ function normalizeEvent(row: any) {
(typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) || (typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) ||
null; null;
// tune these thresholds to match your MES spec const microMultiplier = Number(thresholds?.microMultiplier ?? 1.5);
const MACROSTOP_SEC = 300; // 5 min const macroMultiplier = Math.max(
eventType = stopSec != null && stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop"; microMultiplier,
Number(thresholds?.macroMultiplier ?? 5)
);
const theoreticalCycle =
Number(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time) || 0;
if (stopSec != null) {
if (theoreticalCycle > 0) {
const macroThresholdSec = theoreticalCycle * macroMultiplier;
eventType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
} else {
const fallbackMacroSec = 300;
eventType = stopSec >= fallbackMacroSec ? "macrostop" : "microstop";
}
}
} }
// ----------------------------- // -----------------------------
@@ -203,6 +221,17 @@ export async function GET(
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
} }
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
});
const microMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const macroMultiplier = Math.max(
microMultiplier,
Number(orgSettings?.macroStoppageMultiplier ?? 5)
);
const rawEvents = await prisma.machineEvent.findMany({ const rawEvents = await prisma.machineEvent.findMany({
where: { where: {
orgId: session.orgId, orgId: session.orgId,
@@ -224,7 +253,9 @@ export async function GET(
}, },
}); });
const normalized = rawEvents.map(normalizeEvent); const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier, macroMultiplier })
);
const ALLOWED_TYPES = new Set([ const ALLOWED_TYPES = new Set([
"slow-cycle", "slow-cycle",
@@ -308,14 +339,15 @@ const cycles = rawCycles
location: machine.location, location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null, latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null, latestKpi: machine.kpiSnapshots[0] ?? null,
effectiveCycleTime effectiveCycleTime,
}, },
thresholds: {
stoppageMultiplier: microMultiplier,
macroStoppageMultiplier: macroMultiplier,
},
events, events,
cycles cycles
}); });
} }

View File

@@ -3,10 +3,20 @@ import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getBaseUrl } from "@/lib/appUrl"; import { getBaseUrl } from "@/lib/appUrl";
import { normalizePairingCode } from "@/lib/pairingCode"; import { normalizePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
const pairSchema = z.object({
code: z.string().trim().max(16).optional(),
pairingCode: z.string().trim().max(16).optional(),
});
export async function POST(req: Request) { export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const rawCode = String(body.code || body.pairingCode || "").trim(); const parsed = pairSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid pairing payload" }, { status: 400 });
}
const rawCode = String(parsed.data.code || parsed.data.pairingCode || "").trim();
const code = normalizePairingCode(rawCode); const code = normalizePairingCode(rawCode);
if (!code || code.length !== 5) { if (!code || code.length !== 5) {

View File

@@ -3,9 +3,16 @@ import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode"; import { generatePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
const COOKIE_NAME = "mis_session"; const COOKIE_NAME = "mis_session";
const createMachineSchema = z.object({
name: z.string().trim().min(1).max(80),
code: z.string().trim().max(40).optional(),
location: z.string().trim().max(80).optional(),
});
async function requireSession() { async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value; const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null; if (!sessionId) return null;
@@ -79,14 +86,15 @@ export async function POST(req: Request) {
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const name = String(body.name || "").trim(); const parsed = createMachineSchema.safeParse(body);
const codeRaw = String(body.code || "").trim(); if (!parsed.success) {
const locationRaw = String(body.location || "").trim(); return NextResponse.json({ ok: false, error: "Invalid machine payload" }, { status: 400 });
if (!name) {
return NextResponse.json({ ok: false, error: "Machine name is required" }, { status: 400 });
} }
const name = parsed.data.name;
const codeRaw = parsed.data.code ?? "";
const locationRaw = parsed.data.location ?? "";
const existing = await prisma.machine.findFirst({ const existing = await prisma.machine.findFirst({
where: { orgId: session.orgId, name }, where: { orgId: session.orgId, name },
select: { id: true }, select: { id: true },

View File

@@ -2,11 +2,14 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { z } from "zod";
function canManageMembers(role?: string | null) { function canManageMembers(role?: string | null) {
return role === "OWNER" || role === "ADMIN"; return role === "OWNER" || role === "ADMIN";
} }
const inviteIdSchema = z.string().uuid();
export async function DELETE( export async function DELETE(
_req: NextRequest, _req: NextRequest,
{ params }: { params: Promise<{ inviteId: string }> } { params }: { params: Promise<{ inviteId: string }> }
@@ -17,6 +20,9 @@ export async function DELETE(
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
const { inviteId } = await params; const { inviteId } = await params;
if (!inviteIdSchema.safeParse(inviteId).success) {
return NextResponse.json({ ok: false, error: "Invalid invite id" }, { status: 400 });
}
const membership = await prisma.orgUser.findUnique({ const membership = await prisma.orgUser.findUnique({
where: { where: {

View File

@@ -4,18 +4,19 @@ import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { buildInviteEmail, sendEmail } from "@/lib/email"; import { buildInviteEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl"; import { getBaseUrl } from "@/lib/appUrl";
import { z } from "zod";
const INVITE_DAYS = 7; const INVITE_DAYS = 7;
const ROLES = new Set(["OWNER", "ADMIN", "MEMBER"]); const ROLES = new Set(["OWNER", "ADMIN", "MEMBER"]);
const inviteSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
role: z.string().trim().toUpperCase().optional(),
});
function canManageMembers(role?: string | null) { function canManageMembers(role?: string | null) {
return role === "OWNER" || role === "ADMIN"; return role === "OWNER" || role === "ADMIN";
} }
function isValidEmail(email: string) {
return email.includes("@") && email.includes(".");
}
export async function GET() { export async function GET() {
try { try {
@@ -97,12 +98,12 @@ export async function POST(req: Request) {
} }
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase(); const parsed = inviteSchema.safeParse(body);
const role = String(body.role || "MEMBER").toUpperCase(); if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid invite payload" }, { status: 400 });
if (!email || !isValidEmail(email)) {
return NextResponse.json({ ok: false, error: "Invalid email" }, { status: 400 });
} }
const email = parsed.data.email.toLowerCase();
const role = String(parsed.data.role || "MEMBER").toUpperCase();
if (!ROLES.has(role)) { if (!ROLES.has(role)) {
return NextResponse.json({ ok: false, error: "Invalid role" }, { status: 400 }); return NextResponse.json({ ok: false, error: "Invalid role" }, { status: 400 });

View File

@@ -15,11 +15,25 @@ import {
validateShiftSchedule, validateShiftSchedule,
validateThresholds, validateThresholds,
} from "@/lib/settings"; } from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> { function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value); return !!value && typeof value === "object" && !Array.isArray(value);
} }
function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const machineIdSchema = z.string().uuid();
const machineSettingsSchema = z
.object({
source: z.string().trim().max(40).optional(),
overrides: z.any().optional(),
})
.passthrough();
function pickAllowedOverrides(raw: any) { function pickAllowedOverrides(raw: any) {
if (!isPlainObject(raw)) return {}; if (!isPlainObject(raw)) return {};
const out: Record<string, any> = {}; const out: Record<string, any> = {};
@@ -29,7 +43,11 @@ function pickAllowedOverrides(raw: any) {
return out; return out;
} }
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) { async function ensureOrgSettings(
tx: Prisma.TransactionClient,
orgId: string,
userId?: string | null
) {
let settings = await tx.orgSettings.findUnique({ let settings = await tx.orgSettings.findUnique({
where: { orgId }, where: { orgId },
}); });
@@ -65,12 +83,13 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10, shiftChangeCompMin: 10,
lunchBreakMin: 30, lunchBreakMin: 30,
stoppageMultiplier: 1.5, stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90, oeeAlertThresholdPct: 90,
performanceThresholdPct: 85, performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5, qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS, alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS, defaultsJson: DEFAULT_DEFAULTS,
updatedBy: userId, updatedBy: userId ?? null,
}, },
}); });
@@ -93,23 +112,40 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
} }
export async function GET( export async function GET(
_req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> } { params }: { params: Promise<{ machineId: string }> }
) { ) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { machineId } = await params; const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({ const session = await requireSession();
where: { id: machineId, orgId: session.orgId }, let orgId: string | null = null;
select: { id: true }, let userId: string | null = null;
}); let machine: { id: string; orgId: string } | null = null;
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); if (session) {
machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
orgId = machine.orgId;
userId = session.userId;
} else {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
machine = await prisma.machine.findFirst({
where: { id: machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const { settings, overrides } = await prisma.$transaction(async (tx) => { const { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId); const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND"); if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
const machineSettings = await tx.machineSettings.findUnique({ const machineSettings = await tx.machineSettings.findUnique({
@@ -140,7 +176,18 @@ export async function PUT(
const session = await requireSession(); const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const { machineId } = await params; const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({ const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId }, where: { id: machineId, orgId: session.orgId },
@@ -150,9 +197,13 @@ export async function PUT(
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower"); const parsed = machineSettingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
}
const source = String(parsed.data.source ?? "control_tower");
let patch = body.overrides ?? body; let patch = parsed.data.overrides ?? parsed.data;
if (patch === null) { if (patch === null) {
patch = null; patch = null;
} }
@@ -238,16 +289,20 @@ export async function PUT(
if (patch?.thresholds) { if (patch?.thresholds) {
patch = { patch = {
...patch, ...patch,
thresholds: { thresholds: {
...patch.thresholds, ...patch.thresholds,
stoppageMultiplier: stoppageMultiplier:
patch.thresholds.stoppageMultiplier !== undefined patch.thresholds.stoppageMultiplier !== undefined
? Number(patch.thresholds.stoppageMultiplier) ? Number(patch.thresholds.stoppageMultiplier)
: patch.thresholds.stoppageMultiplier, : patch.thresholds.stoppageMultiplier,
oeeAlertThresholdPct: macroStoppageMultiplier:
patch.thresholds.oeeAlertThresholdPct !== undefined patch.thresholds.macroStoppageMultiplier !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct) ? Number(patch.thresholds.macroStoppageMultiplier)
: patch.thresholds.oeeAlertThresholdPct, : patch.thresholds.macroStoppageMultiplier,
oeeAlertThresholdPct:
patch.thresholds.oeeAlertThresholdPct !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct)
: patch.thresholds.oeeAlertThresholdPct,
performanceThresholdPct: performanceThresholdPct:
patch.thresholds.performanceThresholdPct !== undefined patch.thresholds.performanceThresholdPct !== undefined
? Number(patch.thresholds.performanceThresholdPct) ? Number(patch.thresholds.performanceThresholdPct)
@@ -318,9 +373,30 @@ export async function PUT(
const overrides = pickAllowedOverrides(saved.overridesJson ?? {}); const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides); const effective = deepMerge(orgPayload, overrides);
return { orgPayload, overrides, effective }; return {
orgPayload,
overrides,
effective,
overridesUpdatedAt: saved.updatedAt,
};
}); });
const overridesUpdatedAt =
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
? result.overridesUpdatedAt.toISOString()
: undefined;
try {
await publishSettingsUpdate({
orgId: session.orgId,
machineId,
version: Number(result.orgPayload.version ?? 0),
source,
overridesUpdatedAt,
});
} catch (err) {
console.warn("[settings machine PUT] MQTT publish failed", err);
}
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
machineId, machineId,

View File

@@ -16,11 +16,29 @@ import {
validateShiftSchedule, validateShiftSchedule,
validateThresholds, validateThresholds,
} from "@/lib/settings"; } from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> { function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value); return !!value && typeof value === "object" && !Array.isArray(value);
} }
function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const settingsPayloadSchema = z
.object({
source: z.string().trim().max(40).optional(),
timezone: z.string().trim().max(64).optional(),
shiftSchedule: z.any().optional(),
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) { async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({ let settings = await tx.orgSettings.findUnique({
where: { orgId }, where: { orgId },
@@ -57,6 +75,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10, shiftChangeCompMin: 10,
lunchBreakMin: 30, lunchBreakMin: 30,
stoppageMultiplier: 1.5, stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90, oeeAlertThresholdPct: 90,
performanceThresholdPct: 85, performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5, qualitySpikeDeltaPct: 5,
@@ -108,15 +127,28 @@ export async function PUT(req: Request) {
const session = await requireSession(); const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
try { try {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower"); const parsed = settingsPayloadSchema.safeParse(body);
const timezone = body.timezone; if (!parsed.success) {
const shiftSchedule = body.shiftSchedule; return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
const thresholds = body.thresholds; }
const alerts = body.alerts;
const defaults = body.defaults; const source = String(parsed.data.source ?? "control_tower");
const expectedVersion = body.version; const timezone = parsed.data.timezone;
const shiftSchedule = parsed.data.shiftSchedule;
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const expectedVersion = parsed.data.version;
if ( if (
timezone === undefined && timezone === undefined &&
@@ -192,6 +224,10 @@ export async function PUT(req: Request) {
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined, shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
stoppageMultiplier: stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined, thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier:
thresholds?.macroStoppageMultiplier !== undefined
? Number(thresholds.macroStoppageMultiplier)
: undefined,
oeeAlertThresholdPct: oeeAlertThresholdPct:
thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined, thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined,
performanceThresholdPct: performanceThresholdPct:
@@ -267,6 +303,22 @@ export async function PUT(req: Request) {
} }
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []); const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
const updatedAt =
typeof payload.updatedAt === "string"
? payload.updatedAt
: payload.updatedAt
? payload.updatedAt.toISOString()
: undefined;
try {
await publishSettingsUpdate({
orgId: session.orgId,
version: Number(payload.version ?? 0),
source,
updatedAt,
});
} catch (err) {
console.warn("[settings PUT] MQTT publish failed", err);
}
return NextResponse.json({ ok: true, settings: payload }); return NextResponse.json({ ok: true, settings: payload });
} catch (err) { } catch (err) {
console.error("[settings PUT] failed", err); console.error("[settings PUT] failed", err);

View File

@@ -6,7 +6,14 @@ import { DEFAULT_ALERTS, DEFAULT_DEFAULTS, DEFAULT_SHIFT } from "@/lib/settings"
import { buildVerifyEmail, sendEmail } from "@/lib/email"; import { buildVerifyEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl"; import { getBaseUrl } from "@/lib/appUrl";
import { logLine } from "@/lib/logger"; import { logLine } from "@/lib/logger";
import { z } from "zod";
const signupSchema = z.object({
orgName: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(80),
email: z.string().trim().min(1).max(254).email(),
password: z.string().min(8).max(256),
});
function slugify(input: string) { function slugify(input: string) {
const trimmed = input.trim().toLowerCase(); const trimmed = input.trim().toLowerCase();
@@ -16,28 +23,16 @@ function slugify(input: string) {
return slug || "org"; return slug || "org";
} }
function isValidEmail(email: string) {
return email.includes("@") && email.includes(".");
}
export async function POST(req: Request) { export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const orgName = String(body.orgName || "").trim(); const parsed = signupSchema.safeParse(body);
const name = String(body.name || "").trim(); if (!parsed.success) {
const email = String(body.email || "").trim().toLowerCase(); return NextResponse.json({ ok: false, error: "Invalid signup payload" }, { status: 400 });
const password = String(body.password || "");
if (!orgName || !name || !email || !password) {
return NextResponse.json({ ok: false, error: "Missing required fields" }, { status: 400 });
}
if (!isValidEmail(email)) {
return NextResponse.json({ ok: false, error: "Invalid email" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ ok: false, error: "Password must be at least 8 characters" }, { status: 400 });
} }
const orgName = parsed.data.orgName;
const name = parsed.data.name;
const email = parsed.data.email.toLowerCase();
const password = parsed.data.password;
const existing = await prisma.user.findUnique({ where: { email } }); const existing = await prisma.user.findUnique({ where: { email } });
if (existing) { if (existing) {
@@ -86,6 +81,7 @@ export async function POST(req: Request) {
shiftChangeCompMin: 10, shiftChangeCompMin: 10,
lunchBreakMin: 30, lunchBreakMin: 30,
stoppageMultiplier: 1.5, stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90, oeeAlertThresholdPct: 90,
performanceThresholdPct: 85, performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5, qualitySpikeDeltaPct: 5,

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const { machineId } = await params;
const session = await requireSession();
let orgId: string | null = null;
if (session) {
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
orgId = machine.orgId;
} else {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machine = await prisma.machine.findFirst({
where: { id: machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const rows = await prisma.machineWorkOrder.findMany({
where: { machineId, orgId: orgId as string, status: { not: "DONE" } },
orderBy: { createdAt: "desc" },
});
return NextResponse.json({
ok: true,
machineId,
workOrders: rows.map((row) => ({
workOrderId: row.workOrderId,
sku: row.sku,
targetQty: row.targetQty,
cycleTime: row.cycleTime,
status: row.status,
})),
});
}

View File

@@ -0,0 +1,164 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { publishWorkOrdersUpdate } from "@/lib/mqtt";
import { z } from "zod";
function canManage(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const MAX_WORK_ORDERS = 2000;
const MAX_WORK_ORDER_ID_LENGTH = 64;
const MAX_SKU_LENGTH = 64;
const MAX_TARGET_QTY = 2_000_000_000;
const MAX_CYCLE_TIME = 86_400;
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
const uploadBodySchema = z.object({
machineId: z.string().trim().min(1),
workOrders: z.array(z.any()).optional(),
orders: z.array(z.any()).optional(),
workOrder: z.any().optional(),
});
function cleanText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
const sanitized = text.replace(/[\u0000-\u001f\u007f]/g, "");
if (!sanitized) return null;
return sanitized.length > maxLen ? sanitized.slice(0, maxLen) : sanitized;
}
function toIntOrNull(value: unknown) {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return Math.trunc(n);
}
function toFloatOrNull(value: unknown) {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return n;
}
type WorkOrderInput = {
workOrderId: string;
sku?: string | null;
targetQty?: number | null;
cycleTime?: number | null;
};
function normalizeWorkOrders(raw: any[]) {
const seen = new Set<string>();
const cleaned: WorkOrderInput[] = [];
for (const item of raw) {
const idRaw = cleanText(item?.workOrderId ?? item?.id ?? item?.work_order_id, MAX_WORK_ORDER_ID_LENGTH);
if (!idRaw || !WORK_ORDER_ID_RE.test(idRaw) || seen.has(idRaw)) continue;
seen.add(idRaw);
const sku = cleanText(item?.sku ?? item?.SKU ?? null, MAX_SKU_LENGTH);
const targetQtyRaw = toIntOrNull(item?.targetQty ?? item?.target_qty ?? item?.target ?? item?.targetQuantity);
const cycleTimeRaw = toFloatOrNull(
item?.cycleTime ?? item?.theoreticalCycleTime ?? item?.theoretical_cycle_time ?? item?.cycle_time
);
const targetQty =
targetQtyRaw == null ? null : Math.min(Math.max(targetQtyRaw, 0), MAX_TARGET_QTY);
const cycleTime =
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
cleaned.push({
workOrderId: idRaw,
sku: sku ?? null,
targetQty: targetQty ?? null,
cycleTime: cycleTime ?? null,
});
}
return cleaned;
}
export async function POST(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManage(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const parsedBody = uploadBodySchema.safeParse(body);
if (!parsedBody.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machineId = String(parsedBody.data.machineId ?? "").trim();
if (!machineId) {
return NextResponse.json({ ok: false, error: "machineId is required" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const listRaw = Array.isArray(parsedBody.data.workOrders)
? parsedBody.data.workOrders
: Array.isArray(parsedBody.data.orders)
? parsedBody.data.orders
: parsedBody.data.workOrder
? [parsedBody.data.workOrder]
: [];
if (listRaw.length > MAX_WORK_ORDERS) {
return NextResponse.json(
{ ok: false, error: `Too many work orders (max ${MAX_WORK_ORDERS})` },
{ status: 400 }
);
}
const cleaned = normalizeWorkOrders(listRaw);
if (!cleaned.length) {
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
}
const created = await prisma.machineWorkOrder.createMany({
data: cleaned.map((row) => ({
orgId: session.orgId,
machineId,
workOrderId: row.workOrderId,
sku: row.sku ?? null,
targetQty: row.targetQty ?? null,
cycleTime: row.cycleTime ?? null,
status: "PENDING",
})),
skipDuplicates: true,
});
try {
await publishWorkOrdersUpdate({
orgId: session.orgId,
machineId,
count: created.count,
});
} catch (err) {
console.warn("[work orders POST] MQTT publish failed", err);
}
return NextResponse.json({
ok: true,
machineId,
inserted: created.count,
total: cleaned.length,
});
}

View File

@@ -178,6 +178,14 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
| machine.detail.error.failed | Failed to load machine | No se pudo cargar la máquina | | machine.detail.error.failed | Failed to load machine | No se pudo cargar la máquina |
| machine.detail.error.network | Network error | Error de red | | machine.detail.error.network | Network error | Error de red |
| machine.detail.back | Back | Volver | | machine.detail.back | Back | Volver |
| machine.detail.workOrders.upload | Upload Work Orders | Subir ordenes de trabajo |
| machine.detail.workOrders.uploading | Uploading... | Subiendo... |
| machine.detail.workOrders.uploadParsing | Parsing file... | Leyendo archivo... |
| machine.detail.workOrders.uploadHint | CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity. | CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity. |
| machine.detail.workOrders.uploadSuccess | Uploaded {count} work orders | Se cargaron {count} ordenes de trabajo |
| machine.detail.workOrders.uploadError | Upload failed | No se pudo cargar |
| machine.detail.workOrders.uploadInvalid | No valid work orders found | No se encontraron ordenes de trabajo validas |
| machine.detail.workOrders.uploadUnauthorized | Not authorized to upload work orders | No autorizado para cargar ordenes de trabajo |
| machine.detail.status.offline | OFFLINE | FUERA DE LÍNEA | | machine.detail.status.offline | OFFLINE | FUERA DE LÍNEA |
| machine.detail.status.unknown | UNKNOWN | DESCONOCIDO | | machine.detail.status.unknown | UNKNOWN | DESCONOCIDO |
| machine.detail.status.run | RUN | EN MARCHA | | machine.detail.status.run | RUN | EN MARCHA |
@@ -334,6 +342,7 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
| settings.thresholds.stoppage | Stoppage multiplier | Multiplicador de paro | | settings.thresholds.stoppage | Stoppage multiplier | Multiplicador de paro |
| settings.alerts | Alerts | Alertas | | settings.alerts | Alerts | Alertas |
| settings.alertsSubtitle | Choose which alerts to notify. | Elige qué alertas notificar. | | settings.alertsSubtitle | Choose which alerts to notify. | Elige qué alertas notificar. |
| settings.thresholds.macroStoppage | Macro stoppage multiplier | Multiplicador de macroparo |
| settings.alerts.oeeDrop | OEE drop alerts | Alertas por caída de OEE | | settings.alerts.oeeDrop | OEE drop alerts | Alertas por caída de OEE |
| settings.alerts.oeeDropHelper | Notify when OEE falls below threshold | Notificar cuando OEE esté por debajo del umbral | | settings.alerts.oeeDropHelper | Notify when OEE falls below threshold | Notificar cuando OEE esté por debajo del umbral |
| settings.alerts.performanceDegradation | Performance degradation alerts | Alertas por baja de Performance | | settings.alerts.performanceDegradation | Performance degradation alerts | Alertas por baja de Performance |

View File

@@ -130,7 +130,7 @@
"machines.empty": "No machines found for this org.", "machines.empty": "No machines found for this org.",
"machines.status": "Status", "machines.status": "Status",
"machines.status.noHeartbeat": "No heartbeat", "machines.status.noHeartbeat": "No heartbeat",
"machines.status.ok": "OK", "machines.status.ok": "Heartbeat",
"machines.status.offline": "OFFLINE", "machines.status.offline": "OFFLINE",
"machines.status.unknown": "UNKNOWN", "machines.status.unknown": "UNKNOWN",
"machines.lastSeen": "Last seen {time}", "machines.lastSeen": "Last seen {time}",
@@ -140,6 +140,14 @@
"machine.detail.error.failed": "Failed to load machine", "machine.detail.error.failed": "Failed to load machine",
"machine.detail.error.network": "Network error", "machine.detail.error.network": "Network error",
"machine.detail.back": "Back", "machine.detail.back": "Back",
"machine.detail.workOrders.upload": "Upload Work Orders",
"machine.detail.workOrders.uploading": "Uploading...",
"machine.detail.workOrders.uploadParsing": "Parsing file...",
"machine.detail.workOrders.uploadHint": "CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
"machine.detail.workOrders.uploadError": "Upload failed",
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
"machine.detail.workOrders.uploadUnauthorized": "Not authorized to upload work orders",
"machine.detail.status.offline": "OFFLINE", "machine.detail.status.offline": "OFFLINE",
"machine.detail.status.unknown": "UNKNOWN", "machine.detail.status.unknown": "UNKNOWN",
"machine.detail.status.run": "RUN", "machine.detail.status.run": "RUN",
@@ -286,6 +294,7 @@
"settings.thresholds.performance": "Performance threshold", "settings.thresholds.performance": "Performance threshold",
"settings.thresholds.qualitySpike": "Quality spike delta", "settings.thresholds.qualitySpike": "Quality spike delta",
"settings.thresholds.stoppage": "Stoppage multiplier", "settings.thresholds.stoppage": "Stoppage multiplier",
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
"settings.alerts": "Alerts", "settings.alerts": "Alerts",
"settings.alertsSubtitle": "Choose which alerts to notify.", "settings.alertsSubtitle": "Choose which alerts to notify.",
"settings.alerts.oeeDrop": "OEE drop alerts", "settings.alerts.oeeDrop": "OEE drop alerts",

View File

@@ -130,7 +130,7 @@
"machines.empty": "No se encontraron máquinas para esta organización.", "machines.empty": "No se encontraron máquinas para esta organización.",
"machines.status": "Estado", "machines.status": "Estado",
"machines.status.noHeartbeat": "Sin heartbeat", "machines.status.noHeartbeat": "Sin heartbeat",
"machines.status.ok": "OK", "machines.status.ok": "Latido",
"machines.status.offline": "FUERA DE LÍNEA", "machines.status.offline": "FUERA DE LÍNEA",
"machines.status.unknown": "DESCONOCIDO", "machines.status.unknown": "DESCONOCIDO",
"machines.lastSeen": "Visto hace {time}", "machines.lastSeen": "Visto hace {time}",
@@ -140,6 +140,14 @@
"machine.detail.error.failed": "No se pudo cargar la máquina", "machine.detail.error.failed": "No se pudo cargar la máquina",
"machine.detail.error.network": "Error de red", "machine.detail.error.network": "Error de red",
"machine.detail.back": "Volver", "machine.detail.back": "Volver",
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
"machine.detail.workOrders.uploading": "Subiendo...",
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
"machine.detail.workOrders.uploadHint": "CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
"machine.detail.workOrders.uploadError": "No se pudo cargar",
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
"machine.detail.workOrders.uploadUnauthorized": "No autorizado para cargar ordenes de trabajo",
"machine.detail.status.offline": "FUERA DE LÍNEA", "machine.detail.status.offline": "FUERA DE LÍNEA",
"machine.detail.status.unknown": "DESCONOCIDO", "machine.detail.status.unknown": "DESCONOCIDO",
"machine.detail.status.run": "EN MARCHA", "machine.detail.status.run": "EN MARCHA",
@@ -286,6 +294,7 @@
"settings.thresholds.performance": "Umbral de Performance", "settings.thresholds.performance": "Umbral de Performance",
"settings.thresholds.qualitySpike": "Delta de pico de calidad", "settings.thresholds.qualitySpike": "Delta de pico de calidad",
"settings.thresholds.stoppage": "Multiplicador de paro", "settings.thresholds.stoppage": "Multiplicador de paro",
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
"settings.alerts": "Alertas", "settings.alerts": "Alertas",
"settings.alertsSubtitle": "Elige qué alertas notificar.", "settings.alertsSubtitle": "Elige qué alertas notificar.",
"settings.alerts.oeeDrop": "Alertas por caída de OEE", "settings.alerts.oeeDrop": "Alertas por caída de OEE",

124
lib/mqtt.ts Normal file
View File

@@ -0,0 +1,124 @@
import "server-only";
import mqtt, { MqttClient } from "mqtt";
type SettingsUpdate = {
orgId: string;
version: number;
source?: string;
updatedAt?: string;
machineId?: string;
overridesUpdatedAt?: string;
};
type WorkOrdersUpdate = {
orgId: string;
machineId: string;
count?: number;
source?: string;
updatedAt?: string;
};
const MQTT_URL = process.env.MQTT_BROKER_URL || "";
const MQTT_USERNAME = process.env.MQTT_USERNAME;
const MQTT_PASSWORD = process.env.MQTT_PASSWORD;
const MQTT_CLIENT_ID = process.env.MQTT_CLIENT_ID;
const MQTT_TOPIC_PREFIX = (process.env.MQTT_TOPIC_PREFIX || "mis").replace(/\/+$/, "");
const MQTT_QOS_RAW = Number(process.env.MQTT_QOS ?? "2");
const MQTT_QOS = MQTT_QOS_RAW === 0 || MQTT_QOS_RAW === 1 || MQTT_QOS_RAW === 2 ? MQTT_QOS_RAW : 2;
let client: MqttClient | null = null;
let connecting: Promise<MqttClient> | null = null;
function buildSettingsTopic(orgId: string, machineId?: string) {
const base = `${MQTT_TOPIC_PREFIX}/org/${orgId}`;
if (machineId) return `${base}/machines/${machineId}/settings/updated`;
return `${base}/settings/updated`;
}
function buildWorkOrdersTopic(orgId: string, machineId: string) {
const base = `${MQTT_TOPIC_PREFIX}/org/${orgId}`;
return `${base}/machines/${machineId}/work_orders/updated`;
}
async function getClient() {
if (!MQTT_URL) return null;
if (client?.connected) return client;
if (connecting) return connecting;
connecting = new Promise((resolve, reject) => {
const next = mqtt.connect(MQTT_URL, {
clientId: MQTT_CLIENT_ID,
username: MQTT_USERNAME,
password: MQTT_PASSWORD,
clean: true,
reconnectPeriod: 5000,
});
next.once("connect", () => {
client = next;
connecting = null;
resolve(next);
});
next.once("error", (err) => {
next.end(true);
client = null;
connecting = null;
reject(err);
});
next.once("close", () => {
client = null;
});
});
return connecting;
}
export async function publishSettingsUpdate(update: SettingsUpdate) {
if (!MQTT_URL) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const mqttClient = await getClient();
if (!mqttClient) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const topic = buildSettingsTopic(update.orgId, update.machineId);
const payload = JSON.stringify({
type: update.machineId ? "machine_settings_updated" : "org_settings_updated",
orgId: update.orgId,
machineId: update.machineId,
version: update.version,
source: update.source || "control_tower",
updatedAt: update.updatedAt,
overridesUpdatedAt: update.overridesUpdatedAt,
});
return new Promise<{ ok: true }>((resolve, reject) => {
mqttClient.publish(topic, payload, { qos: MQTT_QOS }, (err) => {
if (err) return reject(err);
resolve({ ok: true });
});
});
}
export async function publishWorkOrdersUpdate(update: WorkOrdersUpdate) {
if (!MQTT_URL) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const mqttClient = await getClient();
if (!mqttClient) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const topic = buildWorkOrdersTopic(update.orgId, update.machineId);
const payload = JSON.stringify({
type: "work_orders_updated",
orgId: update.orgId,
machineId: update.machineId,
count: update.count ?? null,
source: update.source || "control_tower",
updatedAt: update.updatedAt,
});
return new Promise<{ ok: true }>((resolve, reject) => {
mqttClient.publish(topic, payload, { qos: MQTT_QOS }, (err) => {
if (err) return reject(err);
resolve({ ok: true });
});
});
}

View File

@@ -54,6 +54,7 @@ export function buildSettingsPayload(settings: any, shifts: any[]) {
}, },
thresholds: { thresholds: {
stoppageMultiplier: settings.stoppageMultiplier, stoppageMultiplier: settings.stoppageMultiplier,
macroStoppageMultiplier: settings.macroStoppageMultiplier,
oeeAlertThresholdPct: settings.oeeAlertThresholdPct, oeeAlertThresholdPct: settings.oeeAlertThresholdPct,
performanceThresholdPct: settings.performanceThresholdPct, performanceThresholdPct: settings.performanceThresholdPct,
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct, qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
@@ -159,6 +160,14 @@ export function validateThresholds(thresholds: any) {
} }
} }
const macroStoppage = thresholds.macroStoppageMultiplier;
if (macroStoppage != null) {
const v = Number(macroStoppage);
if (!Number.isFinite(v) || v < 1.1 || v > 20.0) {
return { ok: false, error: "macroStoppageMultiplier must be 1.1-20.0" };
}
}
const oee = thresholds.oeeAlertThresholdPct; const oee = thresholds.oeeAlertThresholdPct;
if (oee != null) { if (oee != null) {
const v = Number(oee); const v = Number(oee);

576
package-lock.json generated
View File

@@ -12,11 +12,13 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"i18n": "^0.15.3", "i18n": "^0.15.3",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"mqtt": "^5.10.0",
"next": "16.0.10", "next": "16.0.10",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"xlsx": "^0.18.5",
"zod": "^4.2.1" "zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
@@ -984,6 +986,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -3175,7 +3186,6 @@
"version": "20.19.27", "version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -3212,12 +3222,30 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0", "version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
@@ -3756,6 +3784,18 @@
"win32" "win32"
] ]
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -3779,6 +3819,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4049,6 +4098,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.9", "version": "2.9.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
@@ -4073,6 +4142,18 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/bowser": { "node_modules/bowser": {
"version": "2.13.1", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
@@ -4104,6 +4185,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/broker-factory": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.11.tgz",
"integrity": "sha512-ex4RuEI0AJOdaIcXe1lu9EqRAVkoYvdcvwLvNcE5UZQzYNqzPY+z0frnlxT4+cUwNVpE//9MwGx4lKiLH+pEcw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-unique-numbers": "^9.0.24",
"tslib": "^2.8.1",
"worker-factory": "^7.0.46"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.1", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -4138,6 +4231,36 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/c12": { "node_modules/c12": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -4247,6 +4370,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4305,6 +4441,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4325,6 +4470,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4332,6 +4483,35 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/confbox": { "node_modules/confbox": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
@@ -4356,6 +4536,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5429,12 +5621,30 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -5525,6 +5735,19 @@
"node": ">=10.0" "node": ">=10.0"
} }
}, },
"node_modules/fast-unique-numbers": {
"version": "9.0.24",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.24.tgz",
"integrity": "sha512-Dv0BYn4waOWse94j16rsZ5w/0zoaCa74O3q6IZjMqaXbtT92Q+Sb6pPk+phGzD8Xh+nueQmSRI3tSCaHKidzKw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.2.5", "version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
@@ -5634,6 +5857,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5940,6 +6172,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -5977,6 +6215,26 @@
"url": "https://github.com/sponsors/mashpie" "url": "https://github.com/sponsors/mashpie"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6024,6 +6282,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6048,6 +6312,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6505,6 +6778,16 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7024,7 +7307,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -7036,6 +7318,55 @@
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/mqtt": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.14.1.tgz",
"integrity": "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/mqtt/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -7215,6 +7546,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
@@ -7593,6 +7934,21 @@
} }
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7714,6 +8070,22 @@
} }
} }
}, },
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -7875,6 +8247,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7919,6 +8297,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-identifier": { "node_modules/safe-identifier": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz",
@@ -8182,6 +8580,30 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8191,6 +8613,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -8212,6 +8655,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8644,6 +9096,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -8705,7 +9163,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
@@ -8793,6 +9250,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
"version": "37.3.6", "version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
@@ -8920,6 +9383,24 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8930,6 +9411,95 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/worker-factory": {
"version": "7.0.46",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.46.tgz",
"integrity": "sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-unique-numbers": "^9.0.24",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.27",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.27.tgz",
"integrity": "sha512-+7ptDduAWj6Wd09Ga0weRFRx/MUwLhExazn+zu3IrwF0N2U2FPqFRR5W3Qz4scnI3cOILzdIEEytIJ2vbeD9Gw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.13",
"worker-timers-worker": "^9.0.11"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.13.tgz",
"integrity": "sha512-PZnHHmqOY5oMKQPyfJhqPI9cb3QFmwD3lCIc/Zip6sShpfG2rvvCVDl0xeabGIspiEpP5exNNIlTUHjgP5VAcg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"broker-factory": "^3.1.11",
"fast-unique-numbers": "^9.0.24",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.11"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.11.tgz",
"integrity": "sha512-pArb5xtgHWImYpXhjg1OFv7JFG0ubmccb73TFoXHXjG830fFj+16N57q9YeBnZX52dn+itRrMoJZ9HaZBVzDaA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1",
"worker-factory": "^7.0.46"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -13,11 +13,13 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"i18n": "^0.15.3", "i18n": "^0.15.3",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"mqtt": "^5.10.0",
"next": "16.0.10", "next": "16.0.10",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"xlsx": "^0.20.2",
"zod": "^4.2.1" "zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "machine_work_orders" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"workOrderId" TEXT NOT NULL,
"sku" TEXT,
"targetQty" INTEGER,
"cycleTime" DOUBLE PRECISION,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "machine_work_orders_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "machine_work_orders_machineId_workOrderId_key" ON "machine_work_orders"("machineId", "workOrderId");
-- CreateIndex
CREATE INDEX "machine_work_orders_orgId_machineId_idx" ON "machine_work_orders"("orgId", "machineId");
-- CreateIndex
CREATE INDEX "machine_work_orders_orgId_workOrderId_idx" ON "machine_work_orders"("orgId", "workOrderId");
-- AddForeignKey
ALTER TABLE "machine_work_orders" ADD CONSTRAINT "machine_work_orders_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "machine_work_orders" ADD CONSTRAINT "machine_work_orders_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "org_settings" (
"org_id" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'UTC',
"shift_change_comp_min" INTEGER NOT NULL DEFAULT 10,
"lunch_break_min" INTEGER NOT NULL DEFAULT 30,
"stoppage_multiplier" DOUBLE PRECISION NOT NULL DEFAULT 1.5,
"oee_alert_threshold_pct" DOUBLE PRECISION NOT NULL DEFAULT 90,
"macro_stoppage_multiplier" DOUBLE PRECISION NOT NULL DEFAULT 5,
"performance_threshold_pct" DOUBLE PRECISION NOT NULL DEFAULT 85,
"quality_spike_delta_pct" DOUBLE PRECISION NOT NULL DEFAULT 5,
"alerts_json" JSONB,
"defaults_json" JSONB,
"version" INTEGER NOT NULL DEFAULT 1,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" TEXT,
CONSTRAINT "org_settings_pkey" PRIMARY KEY ("org_id")
);
-- CreateTable
CREATE TABLE "org_shifts" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "org_shifts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "machine_settings" (
"machine_id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"overrides_json" JSONB,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" TEXT,
CONSTRAINT "machine_settings_pkey" PRIMARY KEY ("machine_id")
);
-- CreateTable
CREATE TABLE "settings_audit" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"machine_id" TEXT,
"actor_id" TEXT,
"source" TEXT NOT NULL,
"payload_json" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "settings_audit_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "org_shifts_org_id_idx" ON "org_shifts"("org_id");
-- CreateIndex
CREATE INDEX "org_shifts_org_id_sort_order_idx" ON "org_shifts"("org_id", "sort_order");
-- CreateIndex
CREATE INDEX "machine_settings_org_id_idx" ON "machine_settings"("org_id");
-- CreateIndex
CREATE INDEX "settings_audit_org_id_created_at_idx" ON "settings_audit"("org_id", "created_at");
-- CreateIndex
CREATE INDEX "settings_audit_machine_id_created_at_idx" ON "settings_audit"("machine_id", "created_at");
-- AddForeignKey
ALTER TABLE "org_settings" ADD CONSTRAINT "org_settings_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "org_shifts" ADD CONSTRAINT "org_shifts_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "machine_settings" ADD CONSTRAINT "machine_settings_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "machine_settings" ADD CONSTRAINT "machine_settings_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settings_audit" ADD CONSTRAINT "settings_audit_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settings_audit" ADD CONSTRAINT "settings_audit_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -19,6 +19,7 @@ model Org {
heartbeats MachineHeartbeat[] heartbeats MachineHeartbeat[]
kpiSnapshots MachineKpiSnapshot[] kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[] events MachineEvent[]
workOrders MachineWorkOrder[]
settings OrgSettings? settings OrgSettings?
shifts OrgShift[] shifts OrgShift[]
machineSettings MachineSettings[] machineSettings MachineSettings[]
@@ -119,6 +120,7 @@ model Machine {
kpiSnapshots MachineKpiSnapshot[] kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[] events MachineEvent[]
cycles MachineCycle[] cycles MachineCycle[]
workOrders MachineWorkOrder[]
settings MachineSettings? settings MachineSettings?
settingsAudits SettingsAudit[] settingsAudits SettingsAudit[]
@@ -239,6 +241,27 @@ model MachineCycle {
@@index([orgId, machineId, cycleCount]) @@index([orgId, machineId, cycleCount])
} }
model MachineWorkOrder {
id String @id @default(uuid())
orgId String
machineId String
workOrderId String
sku String?
targetQty Int?
cycleTime Float?
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@unique([machineId, workOrderId])
@@index([orgId, machineId])
@@index([orgId, workOrderId])
@@map("machine_work_orders")
}
model IngestLog { model IngestLog {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String? orgId String?
@@ -269,6 +292,7 @@ model OrgSettings {
lunchBreakMin Int @default(30) @map("lunch_break_min") lunchBreakMin Int @default(30) @map("lunch_break_min")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier") stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct") oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
performanceThresholdPct Float @default(85) @map("performance_threshold_pct") performanceThresholdPct Float @default(85) @map("performance_threshold_pct")
qualitySpikeDeltaPct Float @default(5) @map("quality_spike_delta_pct") qualitySpikeDeltaPct Float @default(5) @map("quality_spike_delta_pct")
alertsJson Json? @map("alerts_json") alertsJson Json? @map("alerts_json")

View File

@@ -53,6 +53,7 @@ async function main() {
lunchBreakMin: 30, lunchBreakMin: 30,
stoppageMultiplier: 1.5, stoppageMultiplier: 1.5,
oeeAlertThresholdPct: 90, oeeAlertThresholdPct: 90,
macroStoppageMultiplier: 5,
performanceThresholdPct: 85, performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5, qualitySpikeDeltaPct: 5,
alertsJson: { alertsJson: {