336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
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";
|
|
import { publishSettingsUpdate } from "@/lib/mqtt";
|
|
import { z } from "zod";
|
|
|
|
type ValidShift = {
|
|
name: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
sortOrder: number;
|
|
enabled: boolean;
|
|
};
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
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 },
|
|
});
|
|
|
|
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,
|
|
macroStoppageMultiplier: 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 });
|
|
|
|
try {
|
|
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 });
|
|
} catch (err) {
|
|
console.error("[settings GET] failed", err);
|
|
const message = err instanceof Error ? err.message : "Internal error";
|
|
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
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 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 &&
|
|
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: ValidShift[] | null = null;
|
|
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 ?? [];
|
|
}
|
|
const shiftRowsSafe = shiftRows ?? [];
|
|
|
|
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,
|
|
macroStoppageMultiplier:
|
|
thresholds?.macroStoppageMultiplier !== undefined
|
|
? Number(thresholds.macroStoppageMultiplier)
|
|
: 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 hasShiftUpdate = shiftRows !== null;
|
|
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 (shiftRowsSafe.length) {
|
|
await tx.orgShift.createMany({
|
|
data: shiftRowsSafe.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 ("error" in updated && updated.error === "VERSION_MISMATCH") {
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Version mismatch", currentVersion: updated.currentVersion },
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
if ("error" in updated) {
|
|
return NextResponse.json({ ok: false, error: updated.error }, { status: 400 });
|
|
}
|
|
|
|
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);
|
|
const message = err instanceof Error ? err.message : "Internal error";
|
|
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
|
}
|
|
}
|