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,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
function unwrapEnvelope(raw: any) {
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) {
const apiKey = req.headers.get("x-api-key");
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);
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 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
where: { id: parsed.data.machineId, apiKey },
select: { id: true, orgId: true },
});
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 =
(typeof c.timestamp === "number" && c.timestamp) ||
(typeof c.ts === "number" && c.ts) ||
(typeof c.event_timestamp === "number" && c.event_timestamp) ||
(typeof body.tsMs === "number" && body.tsMs) ||
(typeof body.tsDevice === "number" && body.tsDevice) ||
(typeof raw?.tsMs === "number" && raw.tsMs) ||
(typeof raw?.tsDevice === "number" && raw.tsDevice) ||
undefined;
const ts = tsMs ? new Date(tsMs) : new Date();
@@ -60,8 +96,8 @@ export async function POST(req: Request) {
machineId: machine.id,
ts,
cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null,
actualCycleTime: Number(c.actual_cycle_time),
theoreticalCycleTime: c.theoretical_cycle_time != null ? Number(c.theoretical_cycle_time) : null,
actualCycleTime: c.actual_cycle_time,
theoreticalCycleTime: typeof c.theoretical_cycle_time === "number" ? c.theoretical_cycle_time : null,
workOrderId: c.work_order_id ? String(c.work_order_id) : null,
sku: c.sku ? String(c.sku) : null,
cavities: typeof c.cavities === "number" ? c.cavities : null,

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const normalizeType = (t: any) =>
String(t ?? "")
@@ -33,9 +34,19 @@ const ALLOWED_TYPES = new Set([
"predictive-oee-decline",
]);
// thresholds for stop classification (tune later / move to machine config)
const MICROSTOP_SEC = 60;
const MACROSTOP_SEC = 300;
const machineIdSchema = z.string().uuid();
const MAX_EVENTS = 100;
//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) {
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({
where: { id: String(machineId), apiKey },
select: { id: true, orgId: true },
});
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
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 skipped: any[] = [];
@@ -112,7 +141,29 @@ export async function POST(req: Request) {
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 {
// missing duration -> conservative
finalType = "microstop";
@@ -125,13 +176,13 @@ export async function POST(req: Request) {
}
const title =
String((ev as any).title ?? "").trim() ||
clampText((ev as any).title, 160) ||
(finalType === "slow-cycle" ? "Slow Cycle Detected" :
finalType === "macrostop" ? "Macrostop Detected" :
finalType === "microstop" ? "Microstop Detected" :
"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
const rawData = (ev as any).data ?? ev;
@@ -144,7 +195,7 @@ export async function POST(req: Request) {
orgId: machine.orgId,
machineId: machine.id,
ts,
topic: String((ev as any).topic ?? finalType),
topic: clampText((ev as any).topic ?? finalType, 64) ?? finalType,
eventType: finalType,
severity: sev,
requiresAck: !!(ev as any).requires_ack,
@@ -152,13 +203,13 @@ export async function POST(req: Request) {
description,
data: dataObj,
workOrderId:
(ev as any)?.work_order_id ? String((ev as any).work_order_id)
: (ev as any)?.data?.work_order_id ? String((ev as any).data.work_order_id)
: null,
clampText((ev as any)?.work_order_id, 64) ??
clampText((ev as any)?.data?.work_order_id, 64) ??
null,
sku:
(ev as any)?.sku ? String((ev as any).sku)
: (ev as any)?.data?.sku ? String((ev as any).data.sku)
: null,
clampText((ev as any)?.sku, 64) ??
clampText((ev as any)?.data?.sku, 64) ??
null,
},
});

View File

@@ -3,6 +3,13 @@ import type { NextRequest } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
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) {
return prisma.orgInvite.findFirst({
@@ -23,6 +30,9 @@ export async function GET(
{ params }: { params: Promise<{ token: string }> }
) {
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);
if (!invite) {
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
@@ -44,23 +54,26 @@ export async function POST(
{ params }: { params: Promise<{ token: string }> }
) {
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);
if (!invite) {
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
}
const body = await req.json().catch(() => ({}));
const name = String(body.name || "").trim();
const password = String(body.password || "");
const parsed = acceptSchema.safeParse(body);
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({
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) {
return NextResponse.json({ ok: false, error: "Name is required" }, { status: 400 });
}

View File

@@ -1,19 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const COOKIE_NAME = "mis_session";
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) {
const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
const next = String(body.next || "/machines");
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid login payload" }, { 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 } });
if (!user || !user.isActive) {

View File

@@ -2,16 +2,30 @@ import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
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) {
const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
const next = String(body.next || "/machines");
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid login payload" }, { 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 } });
if (!user || !user.isActive) {

View File

@@ -3,7 +3,10 @@ import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
function normalizeEvent(row: any) {
function normalizeEvent(
row: any,
thresholds: { microMultiplier: number; macroMultiplier: number }
) {
// -----------------------------
// 1) Parse row.data safely
// data may be:
@@ -91,9 +94,24 @@ function normalizeEvent(row: any) {
(typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) ||
null;
// tune these thresholds to match your MES spec
const MACROSTOP_SEC = 300; // 5 min
eventType = stopSec != null && stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop";
const microMultiplier = Number(thresholds?.microMultiplier ?? 1.5);
const macroMultiplier = Math.max(
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 });
}
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({
where: {
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([
"slow-cycle",
@@ -308,14 +339,15 @@ const cycles = rawCycles
location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null,
effectiveCycleTime
effectiveCycleTime,
},
thresholds: {
stoppageMultiplier: microMultiplier,
macroStoppageMultiplier: macroMultiplier,
},
events,
cycles
});
}

View File

@@ -3,10 +3,20 @@ import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { getBaseUrl } from "@/lib/appUrl";
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) {
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);
if (!code || code.length !== 5) {

View File

@@ -3,9 +3,16 @@ import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
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() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
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 });
const body = await req.json().catch(() => ({}));
const name = String(body.name || "").trim();
const codeRaw = String(body.code || "").trim();
const locationRaw = String(body.location || "").trim();
if (!name) {
return NextResponse.json({ ok: false, error: "Machine name is required" }, { status: 400 });
const parsed = createMachineSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid machine payload" }, { status: 400 });
}
const name = parsed.data.name;
const codeRaw = parsed.data.code ?? "";
const locationRaw = parsed.data.location ?? "";
const existing = await prisma.machine.findFirst({
where: { orgId: session.orgId, name },
select: { id: true },

View File

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

View File

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

View File

@@ -15,11 +15,25 @@ import {
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> {
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) {
if (!isPlainObject(raw)) return {};
const out: Record<string, any> = {};
@@ -29,7 +43,11 @@ function pickAllowedOverrides(raw: any) {
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({
where: { orgId },
});
@@ -65,12 +83,13 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS,
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(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
const session = await requireSession();
let orgId: string | null = null;
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 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");
const machineSettings = await tx.machineSettings.findUnique({
@@ -140,7 +176,18 @@ export async function PUT(
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 (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
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({
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 });
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) {
patch = null;
}
@@ -238,16 +289,20 @@ export async function PUT(
if (patch?.thresholds) {
patch = {
...patch,
thresholds: {
...patch.thresholds,
stoppageMultiplier:
patch.thresholds.stoppageMultiplier !== undefined
? Number(patch.thresholds.stoppageMultiplier)
: patch.thresholds.stoppageMultiplier,
oeeAlertThresholdPct:
patch.thresholds.oeeAlertThresholdPct !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct)
: patch.thresholds.oeeAlertThresholdPct,
thresholds: {
...patch.thresholds,
stoppageMultiplier:
patch.thresholds.stoppageMultiplier !== undefined
? Number(patch.thresholds.stoppageMultiplier)
: patch.thresholds.stoppageMultiplier,
macroStoppageMultiplier:
patch.thresholds.macroStoppageMultiplier !== undefined
? Number(patch.thresholds.macroStoppageMultiplier)
: patch.thresholds.macroStoppageMultiplier,
oeeAlertThresholdPct:
patch.thresholds.oeeAlertThresholdPct !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct)
: patch.thresholds.oeeAlertThresholdPct,
performanceThresholdPct:
patch.thresholds.performanceThresholdPct !== undefined
? Number(patch.thresholds.performanceThresholdPct)
@@ -318,9 +373,30 @@ export async function PUT(
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
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({
ok: true,
machineId,

View File

@@ -16,11 +16,29 @@ import {
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> {
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) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
@@ -57,6 +75,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
@@ -108,15 +127,28 @@ export async function PUT(req: Request) {
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 (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
try {
const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower");
const timezone = body.timezone;
const shiftSchedule = body.shiftSchedule;
const thresholds = body.thresholds;
const alerts = body.alerts;
const defaults = body.defaults;
const expectedVersion = body.version;
const parsed = settingsPayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
}
const source = String(parsed.data.source ?? "control_tower");
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 (
timezone === undefined &&
@@ -192,6 +224,10 @@ export async function PUT(req: Request) {
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier:
thresholds?.macroStoppageMultiplier !== undefined
? Number(thresholds.macroStoppageMultiplier)
: undefined,
oeeAlertThresholdPct:
thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined,
performanceThresholdPct:
@@ -267,6 +303,22 @@ export async function PUT(req: Request) {
}
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 });
} catch (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 { getBaseUrl } from "@/lib/appUrl";
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) {
const trimmed = input.trim().toLowerCase();
@@ -16,28 +23,16 @@ function slugify(input: string) {
return slug || "org";
}
function isValidEmail(email: string) {
return email.includes("@") && email.includes(".");
}
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const orgName = String(body.orgName || "").trim();
const name = String(body.name || "").trim();
const email = String(body.email || "").trim().toLowerCase();
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 parsed = signupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid signup payload" }, { 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 } });
if (existing) {
@@ -86,6 +81,7 @@ export async function POST(req: Request) {
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
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,
});
}