All pages active

This commit is contained in:
Marcelo Dares
2026-01-02 16:56:09 +00:00
parent 363c9fbf4f
commit d172eaf629
11 changed files with 2990 additions and 67 deletions

View File

@@ -33,14 +33,34 @@ export async function GET() {
take: 1,
select: { ts: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
},
},
});
// flatten latest heartbeat for UI convenience
const out = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
latestKpi: m.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
return NextResponse.json({ ok: true, machines: out });

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
function parseDate(input?: string | null) {
if (!input) return null;
const n = Number(input);
if (!Number.isNaN(n)) return new Date(n);
const d = new Date(input);
return Number.isNaN(d.getTime()) ? null : d;
}
function pickRange(req: NextRequest) {
const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const now = new Date();
if (range === "custom") {
const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]);
const end = parseDate(url.searchParams.get("end")) ?? now;
return { start, end };
}
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
return { start: new Date(now.getTime() - ms), end: now };
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const url = new URL(req.url);
const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req);
const baseWhere = {
orgId: session.orgId,
...(machineId ? { machineId } : {}),
ts: { gte: start, lte: end },
};
const workOrderRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, workOrderId: { not: null } },
distinct: ["workOrderId"],
select: { workOrderId: true },
});
const skuRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, sku: { not: null } },
distinct: ["sku"],
select: { sku: true },
});
const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[];
const skus = skuRows.map((r) => r.sku).filter(Boolean) as string[];
return NextResponse.json({ ok: true, workOrders, skus });
}

368
app/api/reports/route.ts Normal file
View File

@@ -0,0 +1,368 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
function parseDate(input?: string | null) {
if (!input) return null;
const n = Number(input);
if (!Number.isNaN(n)) return new Date(n);
const d = new Date(input);
return Number.isNaN(d.getTime()) ? null : d;
}
function pickRange(req: NextRequest) {
const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const now = new Date();
if (range === "custom") {
const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]);
const end = parseDate(url.searchParams.get("end")) ?? now;
return { start, end };
}
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
return { start: new Date(now.getTime() - ms), end: now };
}
function safeNum(v: unknown) {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const url = new URL(req.url);
const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req);
const workOrderId = url.searchParams.get("workOrderId") ?? undefined;
const sku = url.searchParams.get("sku") ?? undefined;
const baseWhere = {
orgId: session.orgId,
...(machineId ? { machineId } : {}),
...(workOrderId ? { workOrderId } : {}),
...(sku ? { sku } : {}),
};
const kpiRows = await prisma.machineKpiSnapshot.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
orderBy: { ts: "asc" },
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
good: true,
scrap: true,
target: true,
machineId: true,
},
});
let oeeSum = 0;
let oeeCount = 0;
let availSum = 0;
let availCount = 0;
let perfSum = 0;
let perfCount = 0;
let qualSum = 0;
let qualCount = 0;
for (const k of kpiRows) {
if (safeNum(k.oee) != null) {
oeeSum += Number(k.oee);
oeeCount += 1;
}
if (safeNum(k.availability) != null) {
availSum += Number(k.availability);
availCount += 1;
}
if (safeNum(k.performance) != null) {
perfSum += Number(k.performance);
perfCount += 1;
}
if (safeNum(k.quality) != null) {
qualSum += Number(k.quality);
qualCount += 1;
}
}
const cycles = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { goodDelta: true, scrapDelta: true },
});
let goodTotal = 0;
let scrapTotal = 0;
for (const c of cycles) {
if (safeNum(c.goodDelta) != null) goodTotal += Number(c.goodDelta);
if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta);
}
const kpiAgg = await prisma.machineKpiSnapshot.groupBy({
by: ["machineId"],
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { good: true, scrap: true, target: true },
_min: { good: true, scrap: true },
_count: { _all: true },
});
let targetTotal = 0;
if (goodTotal === 0 && scrapTotal === 0) {
let goodFallback = 0;
let scrapFallback = 0;
for (const row of kpiAgg) {
const count = row._count._all ?? 0;
const maxGood = safeNum(row._max.good);
const minGood = safeNum(row._min.good);
const maxScrap = safeNum(row._max.scrap);
const minScrap = safeNum(row._min.scrap);
if (count > 1 && maxGood != null && minGood != null) {
goodFallback += Math.max(0, maxGood - minGood);
} else if (maxGood != null) {
goodFallback += maxGood;
}
if (count > 1 && maxScrap != null && minScrap != null) {
scrapFallback += Math.max(0, maxScrap - minScrap);
} else if (maxScrap != null) {
scrapFallback += maxScrap;
}
}
goodTotal = goodFallback;
scrapTotal = scrapFallback;
}
for (const row of kpiAgg) {
const maxTarget = safeNum(row._max.target);
if (maxTarget != null) targetTotal += maxTarget;
}
const events = await prisma.machineEvent.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { eventType: true, data: true },
});
let macrostopSec = 0;
let microstopSec = 0;
let slowCycleCount = 0;
let qualitySpikeCount = 0;
let performanceDegradationCount = 0;
let oeeDropCount = 0;
for (const e of events) {
const type = String(e.eventType ?? "").toLowerCase();
let blob: any = e.data;
if (typeof blob === "string") {
try {
blob = JSON.parse(blob);
} catch {
blob = null;
}
}
const inner = blob?.data ?? blob ?? {};
const stopSec =
(typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) ||
(typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) ||
0;
if (type === "macrostop") macrostopSec += Number(stopSec) || 0;
else if (type === "microstop") microstopSec += Number(stopSec) || 0;
else if (type === "slow-cycle") slowCycleCount += 1;
else if (type === "quality-spike") qualitySpikeCount += 1;
else if (type === "performance-degradation") performanceDegradationCount += 1;
else if (type === "oee-drop") oeeDropCount += 1;
}
type TrendPoint = { t: string; v: number };
const trend: {
oee: TrendPoint[];
availability: TrendPoint[];
performance: TrendPoint[];
quality: TrendPoint[];
scrapRate: TrendPoint[];
} = {
oee: [],
availability: [],
performance: [],
quality: [],
scrapRate: [],
};
for (const k of kpiRows) {
const t = k.ts.toISOString();
if (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) });
if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) });
if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) });
if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) });
const good = safeNum(k.good);
const scrap = safeNum(k.scrap);
if (good != null && scrap != null && good + scrap > 0) {
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
}
}
const cycleRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { actualCycleTime: true },
});
const values = cycleRows
.map((c) => Number(c.actualCycleTime))
.filter((v) => Number.isFinite(v) && v > 0)
.sort((a, b) => a - b);
let cycleTimeBins: {
label: string;
count: number;
rangeStart?: number;
rangeEnd?: number;
overflow?: "low" | "high";
minValue?: number;
maxValue?: number;
}[] = [];
if (values.length) {
const pct = (p: number) => {
const idx = Math.max(0, Math.min(values.length - 1, Math.floor(p * (values.length - 1))));
return values[idx];
};
const p5 = pct(0.05);
const p95 = pct(0.95);
const inRange = values.filter((v) => v >= p5 && v <= p95);
const low = values.filter((v) => v < p5);
const high = values.filter((v) => v > p95);
const binCount = 10;
const span = Math.max(0.1, p95 - p5);
const step = span / binCount;
const counts = new Array(binCount).fill(0);
for (const v of inRange) {
const idx = Math.min(binCount - 1, Math.floor((v - p5) / step));
counts[idx] += 1;
}
const decimals = step < 0.1 ? 2 : step < 1 ? 1 : 0;
cycleTimeBins = counts.map((count, i) => {
const a = p5 + step * i;
const b = p5 + step * (i + 1);
return {
label: `${a.toFixed(decimals)}-${b.toFixed(decimals)}s`,
count,
rangeStart: a,
rangeEnd: b,
};
});
if (low.length) {
cycleTimeBins.unshift({
label: `< ${p5.toFixed(1)}s`,
count: low.length,
rangeEnd: p5,
overflow: "low",
minValue: low[0],
maxValue: low[low.length - 1],
});
}
if (high.length) {
cycleTimeBins.push({
label: `> ${p95.toFixed(1)}s`,
count: high.length,
rangeStart: p95,
overflow: "high",
minValue: high[0],
maxValue: high[high.length - 1],
});
}
}
const scrapRate =
goodTotal + scrapTotal > 0 ? (scrapTotal / (goodTotal + scrapTotal)) * 100 : null;
// top scrap SKU / work order (from cycles)
const scrapBySku = new Map<string, number>();
const scrapByWo = new Map<string, number>();
const scrapRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { sku: true, workOrderId: true, scrapDelta: true },
});
for (const row of scrapRows) {
const scrap = safeNum(row.scrapDelta);
if (scrap == null || scrap <= 0) continue;
if (row.sku) scrapBySku.set(row.sku, (scrapBySku.get(row.sku) ?? 0) + scrap);
if (row.workOrderId) scrapByWo.set(row.workOrderId, (scrapByWo.get(row.workOrderId) ?? 0) + scrap);
}
const topScrapSku = [...scrapBySku.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
const topScrapWorkOrder = [...scrapByWo.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
const oeeAvg = oeeCount ? oeeSum / oeeCount : null;
const availabilityAvg = availCount ? availSum / availCount : null;
const performanceAvg = perfCount ? perfSum / perfCount : null;
const qualityAvg = qualCount ? qualSum / qualCount : null;
// insights
const insights: string[] = [];
if (scrapRate != null && scrapRate > 5) insights.push(`Scrap rate is ${scrapRate.toFixed(1)}% (above 5%).`);
if (performanceAvg != null && performanceAvg < 85) insights.push("Performance below 85%.");
if (availabilityAvg != null && availabilityAvg < 85) insights.push("Availability below 85%.");
if (oeeAvg != null && oeeAvg < 85) insights.push("OEE below 85%.");
if (macrostopSec > 1800) insights.push("Macrostop time exceeds 30 minutes in this range.");
return NextResponse.json({
ok: true,
summary: {
oeeAvg,
availabilityAvg,
performanceAvg,
qualityAvg,
goodTotal,
scrapTotal,
targetTotal,
scrapRate,
topScrapSku,
topScrapWorkOrder,
},
downtime: {
macrostopSec,
microstopSec,
slowCycleCount,
qualitySpikeCount,
performanceDegradationCount,
oeeDropCount,
},
trend,
insights,
distribution: {
cycleTime: cycleTimeBins
},
});
}

View File

@@ -0,0 +1,331 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
DEFAULT_ALERTS,
DEFAULT_DEFAULTS,
DEFAULT_SHIFT,
applyOverridePatch,
buildSettingsPayload,
deepMerge,
validateDefaults,
validateShiftFields,
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function pickAllowedOverrides(raw: any) {
if (!isPlainObject(raw)) return {};
const out: Record<string, any> = {};
for (const key of ["shiftSchedule", "thresholds", "alerts", "defaults"]) {
if (raw[key] !== undefined) out[key] = raw[key];
}
return out;
}
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
});
if (settings) {
let shifts = await tx.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
});
if (!shifts.length) {
await tx.orgShift.create({
data: {
orgId,
name: DEFAULT_SHIFT.name,
startTime: DEFAULT_SHIFT.start,
endTime: DEFAULT_SHIFT.end,
sortOrder: 1,
enabled: true,
},
});
shifts = await tx.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
});
}
return { settings, shifts };
}
settings = await tx.orgSettings.create({
data: {
orgId,
timezone: "UTC",
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS,
updatedBy: userId,
},
});
await tx.orgShift.create({
data: {
orgId,
name: DEFAULT_SHIFT.name,
startTime: DEFAULT_SHIFT.start,
endTime: DEFAULT_SHIFT.end,
sortOrder: 1,
enabled: true,
},
});
const shifts = await tx.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
});
return { settings, shifts };
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { machineId } = await params;
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 { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
const machineSettings = await tx.machineSettings.findUnique({
where: { machineId },
select: { overridesJson: true },
});
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
const effective = deepMerge(orgPayload, rawOverrides);
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
});
return NextResponse.json({
ok: true,
machineId,
orgSettings: settings.org,
effectiveSettings: settings.effective,
overrides,
});
}
export async function PUT(
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;
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 body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower");
let patch = body.overrides ?? body;
if (patch === null) {
patch = null;
}
if (patch && !isPlainObject(patch)) {
return NextResponse.json({ ok: false, error: "overrides must be an object or null" }, { status: 400 });
}
if (patch && Object.keys(patch).length === 0) {
return NextResponse.json({ ok: false, error: "No overrides provided" }, { status: 400 });
}
if (patch && Object.keys(pickAllowedOverrides(patch)).length !== Object.keys(patch).length) {
return NextResponse.json({ ok: false, error: "overrides contain unsupported keys" }, { status: 400 });
}
if (patch?.shiftSchedule && !isPlainObject(patch.shiftSchedule)) {
return NextResponse.json({ ok: false, error: "shiftSchedule must be an object" }, { status: 400 });
}
if (patch?.thresholds !== undefined && patch.thresholds !== null && !isPlainObject(patch.thresholds)) {
return NextResponse.json({ ok: false, error: "thresholds must be an object" }, { status: 400 });
}
if (patch?.alerts !== undefined && patch.alerts !== null && !isPlainObject(patch.alerts)) {
return NextResponse.json({ ok: false, error: "alerts must be an object" }, { status: 400 });
}
if (patch?.defaults !== undefined && patch.defaults !== null && !isPlainObject(patch.defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
const shiftValidation = validateShiftFields(
patch?.shiftSchedule?.shiftChangeCompensationMin,
patch?.shiftSchedule?.lunchBreakMin
);
if (!shiftValidation.ok) {
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
}
const thresholdsValidation = validateThresholds(patch?.thresholds);
if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
}
const defaultsValidation = validateDefaults(patch?.defaults);
if (!defaultsValidation.ok) {
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
}
if (patch?.shiftSchedule?.shifts !== undefined) {
const shiftResult = validateShiftSchedule(patch.shiftSchedule.shifts);
if (!shiftResult.ok) {
return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 });
}
patch = {
...patch,
shiftSchedule: {
...patch.shiftSchedule,
shifts: shiftResult.shifts?.map((s) => ({
name: s.name,
start: s.startTime,
end: s.endTime,
enabled: s.enabled !== false,
})),
},
};
}
if (patch?.shiftSchedule) {
patch = {
...patch,
shiftSchedule: {
...patch.shiftSchedule,
shiftChangeCompensationMin:
patch.shiftSchedule.shiftChangeCompensationMin !== undefined
? Number(patch.shiftSchedule.shiftChangeCompensationMin)
: patch.shiftSchedule.shiftChangeCompensationMin,
lunchBreakMin:
patch.shiftSchedule.lunchBreakMin !== undefined
? Number(patch.shiftSchedule.lunchBreakMin)
: patch.shiftSchedule.lunchBreakMin,
},
};
}
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,
performanceThresholdPct:
patch.thresholds.performanceThresholdPct !== undefined
? Number(patch.thresholds.performanceThresholdPct)
: patch.thresholds.performanceThresholdPct,
qualitySpikeDeltaPct:
patch.thresholds.qualitySpikeDeltaPct !== undefined
? Number(patch.thresholds.qualitySpikeDeltaPct)
: patch.thresholds.qualitySpikeDeltaPct,
},
};
}
if (patch?.defaults) {
patch = {
...patch,
defaults: {
...patch.defaults,
moldTotal:
patch.defaults.moldTotal !== undefined ? Number(patch.defaults.moldTotal) : patch.defaults.moldTotal,
moldActive:
patch.defaults.moldActive !== undefined ? Number(patch.defaults.moldActive) : patch.defaults.moldActive,
},
};
}
const result = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
const existing = await tx.machineSettings.findUnique({
where: { machineId },
select: { overridesJson: true },
});
let nextOverrides: any = null;
if (patch === null) {
nextOverrides = null;
} else {
const merged = applyOverridePatch(existing?.overridesJson ?? {}, patch);
nextOverrides = Object.keys(merged).length ? merged : null;
}
const saved = await tx.machineSettings.upsert({
where: { machineId },
update: {
overridesJson: nextOverrides,
updatedBy: session.userId,
},
create: {
machineId,
orgId: session.orgId,
overridesJson: nextOverrides,
updatedBy: session.userId,
},
});
await tx.settingsAudit.create({
data: {
orgId: session.orgId,
machineId,
actorId: session.userId,
source,
payloadJson: body,
},
});
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides);
return { orgPayload, overrides, effective };
});
return NextResponse.json({
ok: true,
machineId,
orgSettings: result.orgPayload,
effectiveSettings: result.effective,
overrides: result.overrides,
});
}

263
app/api/settings/route.ts Normal file
View File

@@ -0,0 +1,263 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
DEFAULT_ALERTS,
DEFAULT_DEFAULTS,
DEFAULT_SHIFT,
buildSettingsPayload,
normalizeAlerts,
normalizeDefaults,
stripUndefined,
validateDefaults,
validateShiftFields,
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
});
if (settings) {
let shifts = await tx.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
});
if (!shifts.length) {
await tx.orgShift.create({
data: {
orgId,
name: DEFAULT_SHIFT.name,
startTime: DEFAULT_SHIFT.start,
endTime: DEFAULT_SHIFT.end,
sortOrder: 1,
enabled: true,
},
});
shifts = await tx.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
});
}
return { settings, shifts };
}
settings = await tx.orgSettings.create({
data: {
orgId,
timezone: "UTC",
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS,
updatedBy: userId,
},
});
await tx.orgShift.create({
data: {
orgId,
name: DEFAULT_SHIFT.name,
startTime: DEFAULT_SHIFT.start,
endTime: DEFAULT_SHIFT.end,
sortOrder: 1,
enabled: true,
},
});
const shifts = await tx.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
});
return { settings, shifts };
}
export async function GET() {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const loaded = await prisma.$transaction(async (tx) => {
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
return found;
});
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
return NextResponse.json({ ok: true, settings: payload });
}
export async function PUT(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
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;
if (
timezone === undefined &&
shiftSchedule === undefined &&
thresholds === undefined &&
alerts === undefined &&
defaults === undefined
) {
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
}
if (shiftSchedule && !isPlainObject(shiftSchedule)) {
return NextResponse.json({ ok: false, error: "shiftSchedule must be an object" }, { status: 400 });
}
if (thresholds !== undefined && !isPlainObject(thresholds)) {
return NextResponse.json({ ok: false, error: "thresholds must be an object" }, { status: 400 });
}
if (alerts !== undefined && !isPlainObject(alerts)) {
return NextResponse.json({ ok: false, error: "alerts must be an object" }, { status: 400 });
}
if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
const shiftValidation = validateShiftFields(
shiftSchedule?.shiftChangeCompensationMin,
shiftSchedule?.lunchBreakMin
);
if (!shiftValidation.ok) {
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
}
const thresholdsValidation = validateThresholds(thresholds);
if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
}
const defaultsValidation = validateDefaults(defaults);
if (!defaultsValidation.ok) {
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
}
let shiftRows: any[] = [];
let hasShiftUpdate = false;
if (shiftSchedule?.shifts !== undefined) {
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
if (!shiftResult.ok) {
return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 });
}
shiftRows = shiftResult.shifts ?? [];
hasShiftUpdate = true;
}
const updated = await prisma.$transaction(async (tx) => {
const current = await ensureOrgSettings(tx, session.orgId, session.userId);
if (!current?.settings) throw new Error("SETTINGS_NOT_FOUND");
if (expectedVersion != null && Number(expectedVersion) !== Number(current.settings.version)) {
return { error: "VERSION_MISMATCH", currentVersion: current.settings.version } as const;
}
const nextAlerts =
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
const nextDefaults =
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined;
const updateData = stripUndefined({
timezone: timezone !== undefined ? String(timezone) : undefined,
shiftChangeCompMin:
shiftSchedule?.shiftChangeCompensationMin !== undefined
? Number(shiftSchedule.shiftChangeCompensationMin)
: undefined,
lunchBreakMin:
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
oeeAlertThresholdPct:
thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined,
performanceThresholdPct:
thresholds?.performanceThresholdPct !== undefined
? Number(thresholds.performanceThresholdPct)
: undefined,
qualitySpikeDeltaPct:
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
alertsJson: nextAlerts,
defaultsJson: nextDefaults,
});
const hasSettingsUpdate = Object.keys(updateData).length > 0;
if (!hasShiftUpdate && !hasSettingsUpdate) {
return { error: "No settings provided" } as const;
}
const updateWithMeta = {
...updateData,
version: current.settings.version + 1,
updatedBy: session.userId,
};
await tx.orgSettings.update({
where: { orgId: session.orgId },
data: updateWithMeta,
});
if (hasShiftUpdate) {
await tx.orgShift.deleteMany({ where: { orgId: session.orgId } });
if (shiftRows.length) {
await tx.orgShift.createMany({
data: shiftRows.map((s) => ({
...s,
orgId: session.orgId,
})),
});
}
}
const refreshed = await tx.orgSettings.findUnique({
where: { orgId: session.orgId },
});
if (!refreshed) throw new Error("SETTINGS_NOT_FOUND");
const refreshedShifts = await tx.orgShift.findMany({
where: { orgId: session.orgId },
orderBy: { sortOrder: "asc" },
});
await tx.settingsAudit.create({
data: {
orgId: session.orgId,
actorId: session.userId,
source,
payloadJson: body,
},
});
return { settings: refreshed, shifts: refreshedShifts };
});
if ((updated as any)?.error === "VERSION_MISMATCH") {
return NextResponse.json(
{ ok: false, error: "Version mismatch", currentVersion: (updated as any).currentVersion },
{ status: 409 }
);
}
if ((updated as any)?.error) {
return NextResponse.json({ ok: false, error: (updated as any).error }, { status: 400 });
}
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
return NextResponse.json({ ok: true, settings: payload });
}