Final MVP valid
This commit is contained in:
64
app/api/analytics/coverage/route.ts
Normal file
64
app/api/analytics/coverage/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!session) return bad(401, "Unauthorized");
|
||||
const orgId = session.orgId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// ✅ Parse params INSIDE handler
|
||||
const range = coerceDowntimeRange(url.searchParams.get("range"));
|
||||
const start = rangeToStart(range);
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||
|
||||
// coverage is only meaningful for downtime
|
||||
if (kind !== "downtime") return bad(400, "Invalid kind (downtime only)");
|
||||
|
||||
let resolvedMachineId: string | null = null;
|
||||
|
||||
// If machineId provided, validate ownership
|
||||
if (machineId) {
|
||||
const m = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
resolvedMachineId = m.id;
|
||||
}
|
||||
|
||||
const rows = await prisma.reasonEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
...(resolvedMachineId ? { machineId: resolvedMachineId } : {}),
|
||||
kind: "downtime",
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
select: { durationSeconds: true, episodeId: true },
|
||||
});
|
||||
|
||||
const receivedEpisodes = new Set(rows.map((r) => r.episodeId).filter(Boolean)).size;
|
||||
|
||||
const receivedMinutes =
|
||||
Math.round((rows.reduce((acc, r) => acc + (r.durationSeconds ?? 0), 0) / 60) * 10) / 10;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orgId,
|
||||
machineId: resolvedMachineId, // null => org-wide
|
||||
range,
|
||||
start,
|
||||
receivedEpisodes,
|
||||
receivedMinutes,
|
||||
note:
|
||||
"Control Tower received coverage (sync health). True coverage vs total downtime minutes can be added once CT has total downtime minutes per window.",
|
||||
});
|
||||
}
|
||||
130
app/api/analytics/downtime-events/route.ts
Normal file
130
app/api/analytics/downtime-events/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
function toISO(d: Date | null | undefined) {
|
||||
return d ? d.toISOString() : null;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// ✅ Session auth (cookie)
|
||||
const session = await requireSession();
|
||||
if (!session) return bad(401, "Unauthorized");
|
||||
const orgId = session.orgId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// ✅ Params
|
||||
const range = coerceDowntimeRange(url.searchParams.get("range"));
|
||||
const start = rangeToStart(range);
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
||||
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
||||
|
||||
// Optional pagination: return events before this timestamp (capturedAt)
|
||||
const before = url.searchParams.get("before"); // ISO string
|
||||
const beforeDate = before ? new Date(before) : null;
|
||||
if (before && isNaN(beforeDate!.getTime())) return bad(400, "Invalid before timestamp");
|
||||
|
||||
// ✅ If machineId provided, verify it belongs to this org
|
||||
if (machineId) {
|
||||
const m = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
}
|
||||
|
||||
// ✅ Query ReasonEntry as the "episode" table for downtime
|
||||
// We only return rows that have an episodeId (true downtime episodes)
|
||||
const where: any = {
|
||||
orgId,
|
||||
kind: "downtime",
|
||||
episodeId: { not: null },
|
||||
capturedAt: {
|
||||
gte: start,
|
||||
...(beforeDate ? { lt: beforeDate } : {}),
|
||||
},
|
||||
...(machineId ? { machineId } : {}),
|
||||
...(reasonCode ? { reasonCode } : {}),
|
||||
};
|
||||
|
||||
const rows = await prisma.reasonEntry.findMany({
|
||||
where,
|
||||
orderBy: { capturedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
episodeId: true,
|
||||
machineId: true,
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
reasonText: true,
|
||||
durationSeconds: true,
|
||||
capturedAt: true,
|
||||
episodeEndTs: true,
|
||||
workOrderId: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const events = rows.map((r) => {
|
||||
const startAt = r.capturedAt;
|
||||
const endAt =
|
||||
r.episodeEndTs ??
|
||||
(r.durationSeconds != null
|
||||
? new Date(startAt.getTime() + r.durationSeconds * 1000)
|
||||
: null);
|
||||
|
||||
const durationSeconds = r.durationSeconds ?? null;
|
||||
const durationMinutes =
|
||||
durationSeconds != null ? Math.round((durationSeconds / 60) * 10) / 10 : null;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
episodeId: r.episodeId,
|
||||
machineId: r.machineId,
|
||||
machineName: r.machine?.name ?? null,
|
||||
|
||||
reasonCode: r.reasonCode,
|
||||
reasonLabel: r.reasonLabel ?? r.reasonCode,
|
||||
reasonText: r.reasonText ?? null,
|
||||
|
||||
durationSeconds,
|
||||
durationMinutes,
|
||||
|
||||
startAt: toISO(startAt),
|
||||
endAt: toISO(endAt),
|
||||
capturedAt: toISO(r.capturedAt),
|
||||
|
||||
workOrderId: r.workOrderId ?? null,
|
||||
meta: r.meta ?? null,
|
||||
createdAt: toISO(r.createdAt),
|
||||
};
|
||||
});
|
||||
|
||||
const nextBefore =
|
||||
events.length > 0 ? events[events.length - 1]?.capturedAt ?? null : null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orgId,
|
||||
range,
|
||||
start,
|
||||
machineId: machineId ?? null,
|
||||
reasonCode: reasonCode ?? null,
|
||||
limit,
|
||||
before: before ?? null,
|
||||
nextBefore, // pass this back for pagination
|
||||
events,
|
||||
});
|
||||
}
|
||||
123
app/api/analytics/pareto/route.ts
Normal file
123
app/api/analytics/pareto/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// ✅ Session auth (cookie)
|
||||
const session = await requireSession();
|
||||
if (!session) return bad(401, "Unauthorized");
|
||||
const orgId = session.orgId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// ✅ Parse params INSIDE handler
|
||||
const range = coerceDowntimeRange(url.searchParams.get("range"));
|
||||
const start = rangeToStart(range);
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||
|
||||
if (kind !== "downtime" && kind !== "scrap") {
|
||||
return bad(400, "Invalid kind (downtime|scrap)");
|
||||
}
|
||||
|
||||
// ✅ If machineId provided, verify it belongs to this org
|
||||
if (machineId) {
|
||||
const m = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
}
|
||||
|
||||
// ✅ Scope by orgId (+ machineId if provided)
|
||||
const grouped = await prisma.reasonEntry.groupBy({
|
||||
by: ["reasonCode", "reasonLabel"],
|
||||
where: {
|
||||
orgId,
|
||||
...(machineId ? { machineId } : {}),
|
||||
kind,
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
_sum: {
|
||||
durationSeconds: true,
|
||||
scrapQty: true,
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const itemsRaw = grouped
|
||||
.map((g) => {
|
||||
const value =
|
||||
kind === "downtime"
|
||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
||||
: g._sum.scrapQty ?? 0;
|
||||
|
||||
return {
|
||||
reasonCode: g.reasonCode,
|
||||
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
||||
value,
|
||||
count: g._count._all,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.value > 0);
|
||||
|
||||
itemsRaw.sort((a, b) => b.value - a.value);
|
||||
|
||||
const total = itemsRaw.reduce((acc, x) => acc + x.value, 0);
|
||||
|
||||
let cum = 0;
|
||||
let threshold80Index: number | null = null;
|
||||
|
||||
const rows = itemsRaw.map((x, idx) => {
|
||||
const pctOfTotal = total > 0 ? (x.value / total) * 100 : 0;
|
||||
cum += x.value;
|
||||
const cumulativePct = total > 0 ? (cum / total) * 100 : 0;
|
||||
|
||||
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
|
||||
|
||||
return {
|
||||
reasonCode: x.reasonCode,
|
||||
reasonLabel: x.reasonLabel,
|
||||
minutesLost: kind === "downtime" ? x.value : undefined,
|
||||
scrapQty: kind === "scrap" ? x.value : undefined,
|
||||
pctOfTotal,
|
||||
cumulativePct,
|
||||
count: x.count,
|
||||
};
|
||||
});
|
||||
|
||||
const top3 = rows.slice(0, 3);
|
||||
const threshold80 =
|
||||
threshold80Index === null
|
||||
? null
|
||||
: {
|
||||
index: threshold80Index,
|
||||
reasonCode: rows[threshold80Index].reasonCode,
|
||||
reasonLabel: rows[threshold80Index].reasonLabel,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orgId,
|
||||
machineId: machineId ?? null,
|
||||
kind,
|
||||
range, // ✅ now defined correctly
|
||||
start, // ✅ now defined correctly
|
||||
totalMinutesLost: kind === "downtime" ? total : undefined,
|
||||
totalScrap: kind === "scrap" ? total : undefined,
|
||||
rows,
|
||||
top3,
|
||||
threshold80,
|
||||
// (optional) keep old shape if anything else uses it:
|
||||
items: itemsRaw.map((x, i) => ({
|
||||
...x,
|
||||
cumPct: rows[i]?.cumulativePct ?? 0,
|
||||
})),
|
||||
total,
|
||||
});
|
||||
}
|
||||
226
app/api/downtime/actions/[id]/route.ts
Normal file
226
app/api/downtime/actions/[id]/route.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { buildDowntimeActionAssignedEmail, sendEmail } from "@/lib/email";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
|
||||
const STATUS = ["open", "in_progress", "blocked", "done"] as const;
|
||||
const PRIORITY = ["low", "medium", "high"] as const;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const updateSchema = z.object({
|
||||
machineId: z.string().trim().min(1).optional().nullable(),
|
||||
reasonCode: z.string().trim().min(1).max(64).optional().nullable(),
|
||||
hmDay: z.number().int().min(0).max(6).optional().nullable(),
|
||||
hmHour: z.number().int().min(0).max(23).optional().nullable(),
|
||||
title: z.string().trim().min(1).max(160).optional(),
|
||||
notes: z.string().trim().max(4000).optional().nullable(),
|
||||
ownerUserId: z.string().trim().min(1).optional().nullable(),
|
||||
dueDate: z.string().trim().regex(DATE_RE).optional().nullable(),
|
||||
status: z.enum(STATUS).optional(),
|
||||
priority: z.enum(PRIORITY).optional(),
|
||||
});
|
||||
|
||||
function parseDueDate(value?: string | null) {
|
||||
if (value === undefined) return undefined;
|
||||
if (!value) return null;
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function formatDueDate(value?: Date | null) {
|
||||
if (!value) return null;
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
|
||||
const params = new URLSearchParams();
|
||||
if (action.machineId) params.set("machineId", action.machineId);
|
||||
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
|
||||
if (action.hmDay != null && action.hmHour != null) {
|
||||
params.set("hmDay", String(action.hmDay));
|
||||
params.set("hmHour", String(action.hmHour));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
|
||||
}
|
||||
|
||||
function serializeAction(action: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
machineId: string | null;
|
||||
reasonCode: string | null;
|
||||
hmDay: number | null;
|
||||
hmHour: number | null;
|
||||
title: string;
|
||||
notes: string | null;
|
||||
ownerUserId: string | null;
|
||||
dueDate: Date | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
ownerUser?: { name: string | null; email: string } | null;
|
||||
}) {
|
||||
return {
|
||||
id: action.id,
|
||||
createdAt: action.createdAt.toISOString(),
|
||||
updatedAt: action.updatedAt.toISOString(),
|
||||
machineId: action.machineId,
|
||||
reasonCode: action.reasonCode,
|
||||
hmDay: action.hmDay,
|
||||
hmHour: action.hmHour,
|
||||
title: action.title,
|
||||
notes: action.notes ?? "",
|
||||
ownerUserId: action.ownerUserId,
|
||||
ownerName: action.ownerUser?.name ?? null,
|
||||
ownerEmail: action.ownerUser?.email ?? null,
|
||||
dueDate: formatDueDate(action.dueDate),
|
||||
status: action.status,
|
||||
priority: action.priority,
|
||||
};
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid action payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
if (("hmDay" in data || "hmHour" in data) && (data.hmDay == null) !== (data.hmHour == null)) {
|
||||
return NextResponse.json({ ok: false, error: "Heatmap requires hmDay and hmHour" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.downtimeAction.findFirst({
|
||||
where: { id, orgId: session.orgId },
|
||||
include: { ownerUser: { select: { name: true, email: true } } },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ ok: false, error: "Action not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (data.machineId) {
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: data.machineId, orgId: session.orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!machine) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid machineId" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
let ownerMembership: { user: { name: string | null; email: string } } | null = null;
|
||||
if (data.ownerUserId) {
|
||||
ownerMembership = await prisma.orgUser.findUnique({
|
||||
where: { orgId_userId: { orgId: session.orgId, userId: data.ownerUserId } },
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid ownerUserId" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
let completedAt: Date | null | undefined = undefined;
|
||||
if ("status" in data) {
|
||||
completedAt = data.status === "done" ? existing.completedAt ?? new Date() : null;
|
||||
}
|
||||
|
||||
const updateData: Prisma.DowntimeActionUncheckedUpdateInput = {};
|
||||
let shouldResetReminder = false;
|
||||
if ("machineId" in data) updateData.machineId = data.machineId;
|
||||
if ("reasonCode" in data) updateData.reasonCode = data.reasonCode;
|
||||
if ("hmDay" in data) updateData.hmDay = data.hmDay;
|
||||
if ("hmHour" in data) updateData.hmHour = data.hmHour;
|
||||
if ("title" in data) updateData.title = data.title?.trim();
|
||||
if ("notes" in data) updateData.notes = data.notes == null ? null : data.notes.trim() || null;
|
||||
if ("ownerUserId" in data) updateData.ownerUserId = data.ownerUserId;
|
||||
if ("dueDate" in data) {
|
||||
const nextDue = parseDueDate(data.dueDate);
|
||||
const prev = formatDueDate(existing.dueDate);
|
||||
const next = formatDueDate(nextDue ?? null);
|
||||
updateData.dueDate = nextDue;
|
||||
if (prev !== next) {
|
||||
shouldResetReminder = true;
|
||||
}
|
||||
}
|
||||
if ("status" in data) updateData.status = data.status;
|
||||
if ("priority" in data) updateData.priority = data.priority;
|
||||
if (completedAt !== undefined) updateData.completedAt = completedAt;
|
||||
if (shouldResetReminder) {
|
||||
updateData.reminderStage = null;
|
||||
updateData.lastReminderAt = null;
|
||||
}
|
||||
|
||||
const updated = await prisma.downtimeAction.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData,
|
||||
include: { ownerUser: { select: { name: true, email: true } } },
|
||||
});
|
||||
|
||||
const ownerChanged = "ownerUserId" in data && data.ownerUserId !== existing.ownerUserId;
|
||||
const dueChanged =
|
||||
"dueDate" in data && formatDueDate(existing.dueDate) !== formatDueDate(updated.dueDate);
|
||||
|
||||
let emailSent = false;
|
||||
let emailError: string | null = null;
|
||||
if ((ownerChanged || dueChanged) && updated.ownerUser?.email) {
|
||||
try {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: { id: session.orgId },
|
||||
select: { name: true },
|
||||
});
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const actionUrl = buildActionUrl(baseUrl, updated);
|
||||
const content = buildDowntimeActionAssignedEmail({
|
||||
appName: "MIS Control Tower",
|
||||
orgName: org?.name || "your organization",
|
||||
actionTitle: updated.title,
|
||||
assigneeName: updated.ownerUser.name ?? updated.ownerUser.email,
|
||||
dueDate: formatDueDate(updated.dueDate),
|
||||
actionUrl,
|
||||
priority: updated.priority,
|
||||
status: updated.status,
|
||||
});
|
||||
await sendEmail({
|
||||
to: updated.ownerUser.email,
|
||||
subject: content.subject,
|
||||
text: content.text,
|
||||
html: content.html,
|
||||
});
|
||||
emailSent = true;
|
||||
} catch (err: unknown) {
|
||||
emailError = err instanceof Error ? err.message : "Failed to send assignment email";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: serializeAction(updated),
|
||||
emailSent,
|
||||
emailError,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
const existing = await prisma.downtimeAction.findFirst({
|
||||
where: { id, orgId: session.orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ ok: false, error: "Action not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.downtimeAction.delete({ where: { id: existing.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
123
app/api/downtime/actions/reminders/route.ts
Normal file
123
app/api/downtime/actions/reminders/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { buildDowntimeActionReminderEmail, sendEmail } from "@/lib/email";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
|
||||
const DEFAULT_DUE_DAYS = 7;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||
|
||||
type ReminderStage = "week" | "day" | "hour" | "overdue";
|
||||
|
||||
function formatDueDate(value?: Date | null) {
|
||||
if (!value) return null;
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getReminderStage(dueDate: Date, now: Date): ReminderStage | null {
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
if (diffMs <= 0) return "overdue";
|
||||
if (diffMs <= MS_PER_HOUR) return "hour";
|
||||
if (diffMs <= 24 * MS_PER_HOUR) return "day";
|
||||
if (diffMs <= 7 * 24 * MS_PER_HOUR) return "week";
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
|
||||
const params = new URLSearchParams();
|
||||
if (action.machineId) params.set("machineId", action.machineId);
|
||||
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
|
||||
if (action.hmDay != null && action.hmHour != null) {
|
||||
params.set("hmDay", String(action.hmDay));
|
||||
params.set("hmHour", String(action.hmHour));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
|
||||
}
|
||||
|
||||
async function authorizeRequest(req: Request) {
|
||||
const secret = process.env.DOWNTIME_ACTION_REMINDER_SECRET;
|
||||
if (!secret) {
|
||||
const session = await requireSession();
|
||||
return { ok: !!session };
|
||||
}
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
|
||||
const urlToken = new URL(req.url).searchParams.get("token");
|
||||
return { ok: token === secret || urlToken === secret };
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = await authorizeRequest(req);
|
||||
if (!auth.ok) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const dueInDays = Number(sp.get("dueInDays") || DEFAULT_DUE_DAYS);
|
||||
const limit = Number(sp.get("limit") || DEFAULT_LIMIT);
|
||||
|
||||
const now = new Date();
|
||||
const dueBy = new Date(now.getTime() + dueInDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const actions = await prisma.downtimeAction.findMany({
|
||||
where: {
|
||||
status: { not: "done" },
|
||||
ownerUserId: { not: null },
|
||||
dueDate: { not: null, lte: dueBy },
|
||||
},
|
||||
include: {
|
||||
ownerUser: { select: { name: true, email: true } },
|
||||
org: { select: { name: true } },
|
||||
},
|
||||
orderBy: { dueDate: "asc" },
|
||||
take: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : DEFAULT_LIMIT,
|
||||
});
|
||||
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const sentIds: string[] = [];
|
||||
const failures: Array<{ id: string; error: string }> = [];
|
||||
|
||||
for (const action of actions) {
|
||||
const email = action.ownerUser?.email;
|
||||
if (!email) continue;
|
||||
if (!action.dueDate) continue;
|
||||
const stage = getReminderStage(action.dueDate, now);
|
||||
if (!stage) continue;
|
||||
if (action.reminderStage === stage) continue;
|
||||
try {
|
||||
const content = buildDowntimeActionReminderEmail({
|
||||
appName: "MIS Control Tower",
|
||||
orgName: action.org.name,
|
||||
actionTitle: action.title,
|
||||
assigneeName: action.ownerUser?.name ?? email,
|
||||
dueDate: formatDueDate(action.dueDate),
|
||||
actionUrl: buildActionUrl(baseUrl, action),
|
||||
priority: action.priority,
|
||||
status: action.status,
|
||||
});
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: content.subject,
|
||||
text: content.text,
|
||||
html: content.html,
|
||||
});
|
||||
sentIds.push(action.id);
|
||||
await prisma.downtimeAction.update({
|
||||
where: { id: action.id },
|
||||
data: { reminderStage: stage, lastReminderAt: now },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
failures.push({
|
||||
id: action.id,
|
||||
error: err instanceof Error ? err.message : "Failed to send reminder email",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sent: sentIds.length,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
});
|
||||
}
|
||||
226
app/api/downtime/actions/route.ts
Normal file
226
app/api/downtime/actions/route.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { buildDowntimeActionAssignedEmail, sendEmail } from "@/lib/email";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
|
||||
const STATUS = ["open", "in_progress", "blocked", "done"] as const;
|
||||
const PRIORITY = ["low", "medium", "high"] as const;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const createSchema = z.object({
|
||||
machineId: z.string().trim().min(1).optional().nullable(),
|
||||
reasonCode: z.string().trim().min(1).max(64).optional().nullable(),
|
||||
hmDay: z.number().int().min(0).max(6).optional().nullable(),
|
||||
hmHour: z.number().int().min(0).max(23).optional().nullable(),
|
||||
title: z.string().trim().min(1).max(160),
|
||||
notes: z.string().trim().max(4000).optional().nullable(),
|
||||
ownerUserId: z.string().trim().min(1).optional().nullable(),
|
||||
dueDate: z.string().trim().regex(DATE_RE).optional().nullable(),
|
||||
status: z.enum(STATUS).optional(),
|
||||
priority: z.enum(PRIORITY).optional(),
|
||||
});
|
||||
|
||||
function parseDueDate(value?: string | null) {
|
||||
if (!value) return null;
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function formatDueDate(value?: Date | null) {
|
||||
if (!value) return null;
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
|
||||
const params = new URLSearchParams();
|
||||
if (action.machineId) params.set("machineId", action.machineId);
|
||||
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
|
||||
if (action.hmDay != null && action.hmHour != null) {
|
||||
params.set("hmDay", String(action.hmDay));
|
||||
params.set("hmHour", String(action.hmHour));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
|
||||
}
|
||||
|
||||
function serializeAction(action: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
machineId: string | null;
|
||||
reasonCode: string | null;
|
||||
hmDay: number | null;
|
||||
hmHour: number | null;
|
||||
title: string;
|
||||
notes: string | null;
|
||||
ownerUserId: string | null;
|
||||
dueDate: Date | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
ownerUser?: { name: string | null; email: string } | null;
|
||||
}) {
|
||||
return {
|
||||
id: action.id,
|
||||
createdAt: action.createdAt.toISOString(),
|
||||
updatedAt: action.updatedAt.toISOString(),
|
||||
machineId: action.machineId,
|
||||
reasonCode: action.reasonCode,
|
||||
hmDay: action.hmDay,
|
||||
hmHour: action.hmHour,
|
||||
title: action.title,
|
||||
notes: action.notes ?? "",
|
||||
ownerUserId: action.ownerUserId,
|
||||
ownerName: action.ownerUser?.name ?? null,
|
||||
ownerEmail: action.ownerUser?.email ?? null,
|
||||
dueDate: formatDueDate(action.dueDate),
|
||||
status: action.status,
|
||||
priority: action.priority,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const machineId = sp.get("machineId");
|
||||
const reasonCode = sp.get("reasonCode");
|
||||
const hmDayStr = sp.get("hmDay");
|
||||
const hmHourStr = sp.get("hmHour");
|
||||
|
||||
const hmDay = hmDayStr != null ? Number(hmDayStr) : null;
|
||||
const hmHour = hmHourStr != null ? Number(hmHourStr) : null;
|
||||
if ((hmDayStr != null || hmHourStr != null) && (!Number.isFinite(hmDay) || !Number.isFinite(hmHour))) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid heatmap selection" }, { status: 400 });
|
||||
}
|
||||
if ((hmDayStr != null || hmHourStr != null) && (hmDay == null || hmHour == null)) {
|
||||
return NextResponse.json({ ok: false, error: "Heatmap requires hmDay and hmHour" }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: {
|
||||
orgId: string;
|
||||
AND?: Array<Record<string, unknown>>;
|
||||
} = { orgId: session.orgId };
|
||||
|
||||
if (machineId) {
|
||||
where.AND = [...(where.AND ?? []), { OR: [{ machineId }, { machineId: null }] }];
|
||||
}
|
||||
|
||||
if (reasonCode) {
|
||||
where.AND = [...(where.AND ?? []), { OR: [{ reasonCode }, { reasonCode: null }] }];
|
||||
}
|
||||
|
||||
if (hmDay != null && hmHour != null) {
|
||||
where.AND = [
|
||||
...(where.AND ?? []),
|
||||
{ OR: [{ hmDay, hmHour }, { hmDay: null, hmHour: null }] },
|
||||
];
|
||||
}
|
||||
|
||||
const actions = await prisma.downtimeAction.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { ownerUser: { select: { name: true, email: true } } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
actions: actions.map(serializeAction),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid action payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
if ((data.hmDay == null) !== (data.hmHour == null)) {
|
||||
return NextResponse.json({ ok: false, error: "Heatmap requires hmDay and hmHour" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (data.machineId) {
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: data.machineId, orgId: session.orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!machine) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid machineId" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
let ownerMembership: { user: { name: string | null; email: string } } | null = null;
|
||||
if (data.ownerUserId) {
|
||||
ownerMembership = await prisma.orgUser.findUnique({
|
||||
where: { orgId_userId: { orgId: session.orgId, userId: data.ownerUserId } },
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid ownerUserId" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.downtimeAction.create({
|
||||
data: {
|
||||
orgId: session.orgId,
|
||||
machineId: data.machineId ?? null,
|
||||
reasonCode: data.reasonCode ?? null,
|
||||
hmDay: data.hmDay ?? null,
|
||||
hmHour: data.hmHour ?? null,
|
||||
title: data.title.trim(),
|
||||
notes: data.notes?.trim() || null,
|
||||
ownerUserId: data.ownerUserId ?? null,
|
||||
dueDate: parseDueDate(data.dueDate),
|
||||
status: data.status ?? "open",
|
||||
priority: data.priority ?? "medium",
|
||||
completedAt: data.status === "done" ? new Date() : null,
|
||||
createdBy: session.userId,
|
||||
},
|
||||
include: { ownerUser: { select: { name: true, email: true } } },
|
||||
});
|
||||
|
||||
let emailSent = false;
|
||||
let emailError: string | null = null;
|
||||
if (ownerMembership?.user?.email) {
|
||||
try {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: { id: session.orgId },
|
||||
select: { name: true },
|
||||
});
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const actionUrl = buildActionUrl(baseUrl, created);
|
||||
const content = buildDowntimeActionAssignedEmail({
|
||||
appName: "MIS Control Tower",
|
||||
orgName: org?.name || "your organization",
|
||||
actionTitle: created.title,
|
||||
assigneeName: ownerMembership.user.name ?? ownerMembership.user.email,
|
||||
dueDate: formatDueDate(created.dueDate),
|
||||
actionUrl,
|
||||
priority: created.priority,
|
||||
status: created.status,
|
||||
});
|
||||
await sendEmail({
|
||||
to: ownerMembership.user.email,
|
||||
subject: content.subject,
|
||||
text: content.text,
|
||||
html: content.html,
|
||||
});
|
||||
emailSent = true;
|
||||
} catch (err: unknown) {
|
||||
emailError = err instanceof Error ? err.message : "Failed to send assignment email";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: serializeAction(created),
|
||||
emailSent,
|
||||
emailError,
|
||||
});
|
||||
}
|
||||
150
app/api/ingest/reason/route.ts
Normal file
150
app/api/ingest/reason/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
const asTrimmedString = (v: any) => {
|
||||
if (v == null) return "";
|
||||
return String(v).trim();
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = req.headers.get("x-api-key");
|
||||
if (!apiKey) return bad(401, "Missing api key");
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body?.machineId || !body?.reason) return bad(400, "Invalid payload");
|
||||
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: String(body.machineId), apiKey },
|
||||
select: { id: true, orgId: true },
|
||||
});
|
||||
if (!machine) return bad(401, "Unauthorized");
|
||||
|
||||
const r = body.reason;
|
||||
|
||||
const reasonId = asTrimmedString(r.reasonId);
|
||||
if (!reasonId) return bad(400, "Missing reason.reasonId");
|
||||
|
||||
const kind = asTrimmedString(r.kind).toLowerCase();
|
||||
if (kind !== "downtime" && kind !== "scrap")
|
||||
return bad(400, "Invalid reason.kind");
|
||||
|
||||
const capturedAtMs = r.capturedAtMs;
|
||||
if (typeof capturedAtMs !== "number" || !Number.isFinite(capturedAtMs)) {
|
||||
return bad(400, "Invalid reason.capturedAtMs");
|
||||
}
|
||||
const capturedAt = new Date(capturedAtMs);
|
||||
|
||||
const reasonCodeRaw = asTrimmedString(r.reasonCode);
|
||||
if (!reasonCodeRaw) return bad(400, "Missing reason.reasonCode");
|
||||
const reasonCode = reasonCodeRaw.toUpperCase(); // normalize for grouping/pareto
|
||||
|
||||
const reasonLabel = r.reasonLabel != null ? String(r.reasonLabel) : null;
|
||||
|
||||
let reasonText = r.reasonText != null ? String(r.reasonText).trim() : null;
|
||||
if (reasonCode === "OTHER") {
|
||||
if (!reasonText || reasonText.length < 2)
|
||||
return bad(400, "reason.reasonText required when reasonCode=OTHER");
|
||||
} else {
|
||||
// Non-OTHER must not store free text
|
||||
reasonText = null;
|
||||
}
|
||||
|
||||
// Optional shared fields
|
||||
const workOrderId =
|
||||
r.workOrderId != null && String(r.workOrderId).trim()
|
||||
? String(r.workOrderId).trim()
|
||||
: null;
|
||||
|
||||
const schemaVersion =
|
||||
typeof r.schemaVersion === "number" && Number.isFinite(r.schemaVersion)
|
||||
? Math.trunc(r.schemaVersion)
|
||||
: 1;
|
||||
|
||||
const meta = r.meta != null ? r.meta : null;
|
||||
|
||||
// Kind-specific fields
|
||||
let episodeId: string | null = null;
|
||||
let durationSeconds: number | null = null;
|
||||
let episodeEndTs: Date | null = null;
|
||||
|
||||
let scrapEntryId: string | null = null;
|
||||
let scrapQty: number | null = null;
|
||||
let scrapUnit: string | null = null;
|
||||
|
||||
if (kind === "downtime") {
|
||||
episodeId = asTrimmedString(r.episodeId) || null;
|
||||
if (!episodeId) return bad(400, "Missing reason.episodeId for downtime");
|
||||
|
||||
if (typeof r.durationSeconds !== "number" || !Number.isFinite(r.durationSeconds)) {
|
||||
return bad(400, "Invalid reason.durationSeconds for downtime");
|
||||
}
|
||||
durationSeconds = Math.max(0, Math.trunc(r.durationSeconds));
|
||||
|
||||
const episodeEndTsMs = r.episodeEndTsMs;
|
||||
if (episodeEndTsMs != null) {
|
||||
if (typeof episodeEndTsMs !== "number" || !Number.isFinite(episodeEndTsMs)) {
|
||||
return bad(400, "Invalid reason.episodeEndTsMs");
|
||||
}
|
||||
episodeEndTs = new Date(episodeEndTsMs);
|
||||
}
|
||||
} else {
|
||||
scrapEntryId = asTrimmedString(r.scrapEntryId) || null;
|
||||
if (!scrapEntryId) return bad(400, "Missing reason.scrapEntryId for scrap");
|
||||
|
||||
if (typeof r.scrapQty !== "number" || !Number.isFinite(r.scrapQty)) {
|
||||
return bad(400, "Invalid reason.scrapQty for scrap");
|
||||
}
|
||||
scrapQty = Math.max(0, Math.trunc(r.scrapQty));
|
||||
|
||||
scrapUnit =
|
||||
r.scrapUnit != null && String(r.scrapUnit).trim()
|
||||
? String(r.scrapUnit).trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
// Idempotent upsert keyed by reasonId
|
||||
const row = await prisma.reasonEntry.upsert({
|
||||
where: { reasonId },
|
||||
create: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
reasonId,
|
||||
kind,
|
||||
episodeId,
|
||||
durationSeconds,
|
||||
episodeEndTs,
|
||||
scrapEntryId,
|
||||
scrapQty,
|
||||
scrapUnit,
|
||||
reasonCode,
|
||||
reasonLabel,
|
||||
reasonText,
|
||||
capturedAt,
|
||||
workOrderId,
|
||||
meta,
|
||||
schemaVersion,
|
||||
},
|
||||
update: {
|
||||
kind,
|
||||
episodeId,
|
||||
durationSeconds,
|
||||
episodeEndTs,
|
||||
scrapEntryId,
|
||||
scrapQty,
|
||||
scrapUnit,
|
||||
reasonCode,
|
||||
reasonLabel,
|
||||
reasonText,
|
||||
capturedAt,
|
||||
workOrderId,
|
||||
meta,
|
||||
schemaVersion,
|
||||
},
|
||||
select: { id: true, reasonId: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, id: row.id, reasonId: row.reasonId });
|
||||
}
|
||||
@@ -37,6 +37,7 @@ function canManageSettings(role?: string | null) {
|
||||
const settingsPayloadSchema = z
|
||||
.object({
|
||||
source: z.string().trim().max(40).optional(),
|
||||
modules: z.any().optional(),
|
||||
timezone: z.string().trim().max(64).optional(),
|
||||
shiftSchedule: z.any().optional(),
|
||||
thresholds: z.any().optional(),
|
||||
@@ -87,7 +88,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
|
||||
performanceThresholdPct: 85,
|
||||
qualitySpikeDeltaPct: 5,
|
||||
alertsJson: DEFAULT_ALERTS,
|
||||
defaultsJson: DEFAULT_DEFAULTS,
|
||||
defaultsJson: { ...(DEFAULT_DEFAULTS as any), modules: { screenlessMode: false } },
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
@@ -122,7 +123,13 @@ export async function GET() {
|
||||
});
|
||||
|
||||
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
||||
return NextResponse.json({ ok: true, settings: payload });
|
||||
|
||||
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
|
||||
|
||||
} catch (err) {
|
||||
console.error("[settings GET] failed", err);
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
@@ -156,13 +163,18 @@ export async function PUT(req: Request) {
|
||||
const alerts = parsed.data.alerts;
|
||||
const defaults = parsed.data.defaults;
|
||||
const expectedVersion = parsed.data.version;
|
||||
const modules = parsed.data.modules;
|
||||
|
||||
|
||||
|
||||
if (
|
||||
timezone === undefined &&
|
||||
shiftSchedule === undefined &&
|
||||
thresholds === undefined &&
|
||||
alerts === undefined &&
|
||||
defaults === undefined
|
||||
defaults === undefined &&
|
||||
modules === undefined
|
||||
|
||||
) {
|
||||
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
|
||||
}
|
||||
@@ -179,6 +191,16 @@ export async function PUT(req: Request) {
|
||||
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||
}
|
||||
if (modules !== undefined && !isPlainObject(modules)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const screenlessMode =
|
||||
modules && typeof (modules as any).screenlessMode === "boolean"
|
||||
? (modules as any).screenlessMode
|
||||
: undefined;
|
||||
|
||||
|
||||
|
||||
const shiftValidation = validateShiftFields(
|
||||
shiftSchedule?.shiftChangeCompensationMin,
|
||||
@@ -193,11 +215,6 @@ export async function PUT(req: Request) {
|
||||
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);
|
||||
@@ -218,8 +235,34 @@ export async function PUT(req: Request) {
|
||||
|
||||
const nextAlerts =
|
||||
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
|
||||
const nextDefaults =
|
||||
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined;
|
||||
const currentDefaultsRaw = isPlainObject(current.settings.defaultsJson)
|
||||
? (current.settings.defaultsJson as any)
|
||||
: {};
|
||||
const currentModulesRaw = isPlainObject(currentDefaultsRaw.modules) ? currentDefaultsRaw.modules : {};
|
||||
|
||||
// Merge defaults core (moldTotal, etc.)
|
||||
const nextDefaultsCore =
|
||||
defaults !== undefined ? { ...normalizeDefaults(currentDefaultsRaw), ...defaults } : undefined;
|
||||
|
||||
// Validate merged defaults
|
||||
if (nextDefaultsCore) {
|
||||
const dv = validateDefaults(nextDefaultsCore);
|
||||
if (!dv.ok) return { error: dv.error } as const;
|
||||
}
|
||||
|
||||
// Merge modules
|
||||
const nextModules =
|
||||
screenlessMode === undefined
|
||||
? currentModulesRaw
|
||||
: { ...currentModulesRaw, screenlessMode };
|
||||
|
||||
// Write defaultsJson if either defaults changed OR modules changed
|
||||
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
|
||||
|
||||
const nextDefaultsJson = shouldWriteDefaultsJson
|
||||
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
||||
: undefined;
|
||||
|
||||
|
||||
const updateData = stripUndefined({
|
||||
timezone: timezone !== undefined ? String(timezone) : undefined,
|
||||
@@ -244,7 +287,7 @@ export async function PUT(req: Request) {
|
||||
qualitySpikeDeltaPct:
|
||||
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
|
||||
alertsJson: nextAlerts,
|
||||
defaultsJson: nextDefaults,
|
||||
defaultsJson: nextDefaultsJson,
|
||||
});
|
||||
|
||||
const hasShiftUpdate = shiftRows !== null;
|
||||
@@ -326,7 +369,12 @@ export async function PUT(req: Request) {
|
||||
} catch (err) {
|
||||
console.warn("[settings PUT] MQTT publish failed", err);
|
||||
}
|
||||
return NextResponse.json({ ok: true, settings: payload });
|
||||
const defaultsRaw = isPlainObject(updated.settings.defaultsJson) ? (updated.settings.defaultsJson as any) : {};
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
|
||||
|
||||
} catch (err) {
|
||||
console.error("[settings PUT] failed", err);
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
|
||||
Reference in New Issue
Block a user