Full project added

This commit is contained in:
Marcelo Dares
2025-12-17 20:24:06 +00:00
parent fc2e4fd15a
commit 0e9b2dd72d
36 changed files with 2050 additions and 84 deletions

33
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Sidebar } from "@/components/layout/Sidebar";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) redirect("/login?next=/machines");
// validate session in DB (dont trust cookie existence)
const session = await prisma.session.findFirst({
where: {
id: sessionId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: { user: true, org: true },
});
if (!session) redirect("/login?next=/machines");
return (
<div className="min-h-screen bg-black text-white">
<div className="flex">
<Sidebar />
<main className="flex-1 min-h-screen">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
type Heartbeat = {
ts: string;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
type Kpi = {
ts: string;
oee?: number | null;
availability?: number | null;
performance?: number | null;
quality?: number | null;
workOrderId?: string | null;
sku?: string | null;
good?: number | null;
scrap?: number | null;
target?: number | null;
cycleTime?: number | null;
};
type EventRow = {
id: string;
ts: string;
topic: string;
eventType: string;
severity: string;
title: string;
description?: string | null;
requiresAck: boolean;
};
type MachineDetail = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: Heartbeat | null;
latestKpi: Kpi | null;
};
export default function MachineDetailClient() {
const params = useParams<{ machineId: string }>();
const machineId = params?.machineId;
const [loading, setLoading] = useState(true);
const [machine, setMachine] = useState<MachineDetail | null>(null);
const [events, setEvents] = useState<EventRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!machineId) return; // <-- IMPORTANT guard
let alive = true;
async function load() {
try {
const res = await fetch(`/api/machines/${machineId}`, {
cache: "no-store",
credentials: "include",
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? "Failed to load machine");
setLoading(false);
return;
}
setMachine(json.machine ?? null);
setEvents(json.events ?? []);
setError(null);
setLoading(false);
} catch {
if (!alive) return;
setError("Network error");
setLoading(false);
}
}
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, [machineId]);
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return `${v.toFixed(1)}%`;
}
function fmtNum(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return `${v}`;
}
function timeAgo(ts?: string) {
if (!ts) return "never";
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 15000;
}
function statusBadgeClass(status?: string, offline?: boolean) {
if (offline) return "bg-white/10 text-zinc-300";
const s = (status ?? "").toUpperCase();
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
return "bg-white/10 text-white";
}
function severityBadgeClass(sev?: string) {
const s = (sev ?? "").toLowerCase();
if (s === "critical") return "bg-red-500/15 text-red-300";
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
return "bg-white/10 text-zinc-200";
}
const hb = machine?.latestHeartbeat ?? null;
const kpi = machine?.latestKpi ?? null;
const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]);
const statusLabel = offline ? "OFFLINE" : (hb?.status ?? "UNKNOWN");
return (
<div className="p-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate text-2xl font-semibold text-white">
{machine?.name ?? "Machine"}
</h1>
<span className={`rounded-full px-3 py-1 text-xs ${statusBadgeClass(hb?.status, offline)}`}>
{statusLabel}
</span>
</div>
<div className="mt-1 text-sm text-zinc-400">
{machine?.code ? machine.code : "—"} {machine?.location ? machine.location : "—"} Last seen{" "}
{hb?.ts ? timeAgo(hb.ts) : "never"}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href="/machines"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back
</Link>
</div>
</div>
{loading && <div className="text-sm text-zinc-400">Loading</div>}
{error && !loading && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{error}
</div>
)}
{!loading && !error && (
<>
{/* KPI cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">OEE</div>
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
<div className="mt-1 text-xs text-zinc-400">Updated {kpi?.ts ? timeAgo(kpi.ts) : "never"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Availability</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.availability)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Performance</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.performance)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Quality</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.quality)}</div>
</div>
</div>
{/* Work order + recent events */}
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Current Work Order</div>
<div className="text-xs text-zinc-400">{kpi?.workOrderId ?? "—"}</div>
</div>
<div className="text-xs text-zinc-400">SKU</div>
<div className="mt-1 text-base font-semibold text-white">{kpi?.sku ?? "—"}</div>
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Target</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.target)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Good</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.good)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Scrap</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.scrap)}</div>
</div>
</div>
<div className="mt-4 text-xs text-zinc-400">
Cycle target: <span className="text-white">{kpi?.cycleTime ? `${kpi.cycleTime}s` : "—"}</span>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Recent Events</div>
<div className="text-xs text-zinc-400">{events.length} shown</div>
</div>
{events.length === 0 ? (
<div className="text-sm text-zinc-400">No events yet.</div>
) : (
<div className="space-y-3">
{events.map((e) => (
<div key={e.id} className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${severityBadgeClass(e.severity)}`}>
{e.severity.toUpperCase()}
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
{e.eventType}
</span>
{e.requiresAck && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
ACK
</span>
)}
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">{e.title}</div>
{e.description && (
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
)}
</div>
<div className="shrink-0 text-xs text-zinc-400">{timeAgo(e.ts)}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import MachineDetailClient from "./MachineDetailClient";
export default function Page() {
return <MachineDetailClient />;
}

133
app/(app)/machines/page.tsx Normal file
View File

@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
type MachineRow = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: null | {
ts: string;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
};
function secondsAgo(ts?: string) {
if (!ts) return "never";
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
return `${Math.floor(diff / 60)}m ago`;
}
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 15000; // 15s threshold
}
function badgeClass(status?: string, offline?: boolean) {
if (offline) return "bg-white/10 text-zinc-300";
const s = (status ?? "").toUpperCase();
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
return "bg-white/10 text-white";
}
export default function MachinesPage() {
const [machines, setMachines] = useState<MachineRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
async function load() {
try {
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json();
if (alive) {
setMachines(json.machines ?? []);
setLoading(false);
}
} catch {
if (alive) setLoading(false);
}
}
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
return (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Machines</h1>
<p className="text-sm text-zinc-400">Select a machine to view live KPIs.</p>
</div>
<Link
href="/overview"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back to Overview
</Link>
</div>
{loading && <div className="mb-4 text-sm text-zinc-400">Loading machines</div>}
{!loading && machines.length === 0 && (
<div className="mb-4 text-sm text-zinc-400">No machines found for this org.</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{(!loading ? machines : []).map((m) => {
const hb = m.latestHeartbeat;
const offline = isOffline(hb?.ts);
const statusLabel = offline ? "OFFLINE" : hb?.status ?? "UNKNOWN";
const lastSeen = secondsAgo(hb?.ts);
return (
<Link
key={m.id}
href={`/machines/${m.id}`}
className="rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
<div className="mt-1 text-xs text-zinc-400">
{m.code ? m.code : "—"} Last seen {lastSeen}
</div>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
hb?.status,
offline
)}`}
>
{statusLabel}
</span>
</div>
<div className="mt-4 text-sm text-zinc-400">Status</div>
<div className="text-xl font-semibold text-white">
{offline ? "No heartbeat" : hb?.message ?? "OK"}
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
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 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.event) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
// Convert ms epoch -> Date if provided
const e = body.event;
const ts =
typeof e?.data?.timestamp === "number"
? new Date(e.data.timestamp)
: undefined;
// normalize inputs from event
const sev = String(e.severity ?? "").toLowerCase();
const typ = String(e.eventType ?? e.anomaly_type ?? "").toLowerCase();
const title = String(e.title ?? "").trim();
const ALLOWED_TYPES = new Set([
"slow-cycle",
"anomaly-detected",
"performance-degradation",
"scrap-spike",
"down",
"microstop",
]);
const ALLOWED_SEVERITIES = new Set(["warning", "critical"]);
// Drop generic/noise
if (!ALLOWED_SEVERITIES.has(sev) || !ALLOWED_TYPES.has(typ)) {
return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
}
if (!title) return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
const row = await prisma.machineEvent.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
ts: ts ?? undefined,
topic: e.topic ? String(e.topic) : "event",
eventType: e.anomaly_type ? String(e.anomaly_type) : "unknown",
severity: e.severity ? String(e.severity) : "info",
requiresAck: !!e.requires_ack,
title: e.title ? String(e.title) : "Event",
description: e.description ? String(e.description) : null,
data: e.data ?? e, // store full blob
workOrderId: e?.data?.work_order_id ? String(e.data.work_order_id) : null,
},
});
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
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 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.status) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const hb = await prisma.machineHeartbeat.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
status: String(body.status),
message: body.message ? String(body.message) : null,
ip: body.ip ? String(body.ip) : null,
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
},
});
return NextResponse.json({ ok: true, id: hb.id, ts: hb.ts });
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
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 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.kpis) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const wo = body.activeWorkOrder ?? {};
const k = body.kpis ?? {};
const row = await prisma.machineKpiSnapshot.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
workOrderId: wo.id ? String(wo.id) : null,
sku: wo.sku ? String(wo.sku) : null,
target: typeof wo.target === "number" ? wo.target : null,
good: typeof wo.good === "number" ? wo.good : null,
scrap: typeof wo.scrap === "number" ? wo.scrap : null,
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
cycleTime: typeof body.cycleTime === "number" ? body.cycleTime : null,
availability: typeof k.availability === "number" ? k.availability : null,
performance: typeof k.performance === "number" ? k.performance : null,
quality: typeof k.quality === "number" ? k.quality : null,
oee: typeof k.oee === "number" ? k.oee : null,
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
},
});
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
}

60
app/api/login/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
const SESSION_DAYS = 7;
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 user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) {
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
}
// Multiple orgs per user: pick the oldest membership for now
const membership = await prisma.orgUser.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});
if (!membership) {
return NextResponse.json({ ok: false, error: "User has no organization" }, { status: 403 });
}
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
const session = await prisma.session.create({
data: {
userId: user.id,
orgId: membership.orgId,
expiresAt,
// optional fields you can add later: ip/userAgent
},
});
const res = NextResponse.json({ ok: true, next });
res.cookies.set(COOKIE_NAME, session.id, {
httpOnly: true,
sameSite: "lax",
secure: false, // set true once HTTPS only
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
return res;
}

21
app/api/logout/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
export async function POST() {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (sessionId) {
await prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
}).catch(() => {});
}
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
return res;
}

View File

@@ -0,0 +1,78 @@
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: { machineId: string } }
) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = params;
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: {
id: true,
name: true,
code: true,
location: true,
heartbeats: {
orderBy: { ts: "desc" },
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,
},
},
},
});
if (!machine) {
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
}
const events = await prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
severity: { in: ["warning", "critical"] },
eventType: { in: ["slow-cycle", "anomaly-detected", "performance-degradation", "scrap-spike", "down", "microstop"] },
},
orderBy: { ts: "desc" },
take: 30,
select: { /* same as now */ },
});
return NextResponse.json({
ok: true,
machine: {
id: machine.id,
name: machine.name,
code: machine.code,
location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null,
},
events,
});
}

47
app/api/machines/route.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
const COOKIE_NAME = "mis_session";
async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null;
return prisma.session.findFirst({
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
include: { org: true, user: true },
});
}
export async function GET() {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machines = await prisma.machine.findMany({
where: { orgId: session.orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { ts: "desc" },
take: 1,
select: { ts: true, status: true, message: true, ip: true, fwVersion: true },
},
},
});
// flatten latest heartbeat for UI convenience
const out = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
heartbeats: undefined,
}));
return NextResponse.json({ ok: true, machines: out });
}

23
app/api/me/route.ts Normal file
View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const { userId, orgId } = await requireSession();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true },
});
const org = await prisma.org.findUnique({
where: { id: orgId },
select: { id: true, name: true, slug: true },
});
return NextResponse.json({ ok: true, user, org });
} catch {
return NextResponse.json({ ok: false }, { status: 401 });
}
}

View File

@@ -2,31 +2,18 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "MIS Control Tower",
description: "MaliounTech Industrial Suite",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>

86
app/login/LoginForm.tsx Normal file
View File

@@ -0,0 +1,86 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") || "/machines";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, next }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setErr(data.error || "Login failed");
return;
}
router.push(next);
router.refresh();
} catch (e: any) {
setErr(e?.message || "Network error");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-md rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Control Tower</h1>
<p className="mt-1 text-sm text-zinc-400">Sign in to your organization</p>
<div className="mt-6 space-y-4">
<div>
<label className="text-sm text-zinc-300">Email</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</div>
<div>
<label className="text-sm text-zinc-300">Password</label>
<input
type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{err && <div className="text-sm text-red-400">{err}</div>}
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
>
{loading ? "Signing in..." : "Login"}
</button>
<div className="text-xs text-zinc-500">(Dev mode) This will be replaced with JWT auth later.</div>
</div>
</form>
</div>
);
}

22
app/login/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import LoginForm from "./LoginForm"; // adjust path if needed
export default async function LoginPage({
searchParams,
}: {
searchParams?: { next?: string };
}) {
const session = (await cookies()).get("mis_session")?.value;
// If already logged in, send to next or machines
if (session) {
const next = searchParams?.next || "/machines";
redirect(next);
}
// ...your existing login UI below
return <LoginForm />; // ✅ actually render it
}

7
app/logout/route.ts Normal file
View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set("mis_session", "", { path: "/", maxAge: 0 });
return res;
}

11
app/mdares.code-workspace Normal file
View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../.."
},
{
"path": "../../../../etc/nginx/sites-available"
}
],
"settings": {}
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/machines");
}

View File

@@ -0,0 +1,29 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [ready, setReady] = useState(false);
useEffect(() => {
const token = localStorage.getItem("ct_token");
if (!token) {
router.replace("/login");
return;
}
setReady(true);
}, [router, pathname]);
if (!ready) {
return (
<div className="min-h-screen bg-[#070A0C] text-zinc-200 flex items-center justify-center">
Loading
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
const items = [
{ href: "/overview", label: "Overview", icon: "🏠" },
{ href: "/machines", label: "Machines", icon: "🏭" },
{ href: "/reports", label: "Reports", icon: "📊" },
{ href: "/settings", label: "Settings", icon: "⚙️" },
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
async function onLogout() {
await fetch("/api/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<aside className="hidden md:flex h-screen w-64 flex-col border-r border-white/10 bg-black/40">
<div className="px-5 py-4">
<div className="text-white font-semibold tracking-wide">MIS</div>
<div className="text-xs text-zinc-500">Control Tower</div>
</div>
<nav className="px-3 py-2 flex-1 space-y-1">
{items.map((it) => {
const active = pathname === it.href || pathname.startsWith(it.href + "/");
return (
<Link
key={it.href}
href={it.href}
className={[
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
active
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
: "text-zinc-300 hover:bg-white/5 hover:text-white",
].join(" ")}
>
<span className="text-lg">{it.icon}</span>
<span>{it.label}</span>
</Link>
);
})}
</nav>
<div className="px-5 py-4 border-t border-white/10 space-y-3">
<div>
<div className="text-sm text-white">Juan Pérez</div>
<div className="text-xs text-zinc-500">Plant Manager</div>
</div>
<button
onClick={onLogout}
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-200 hover:bg-white/10"
>
🚪 Logout
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
export function Topbar({ title }: { title: string }) {
return (
<div className="h-16 flex items-center justify-between px-4 border-b border-white/10 bg-black/20 backdrop-blur">
<div className="text-lg font-semibold tracking-tight">{title}</div>
<div className="text-xs text-zinc-400">
Live (mock for now)
</div>
</div>
);
}

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 1766534242 mis_session b96c3e1d-191d-470c-b7ec-339799887bba

View File

@@ -0,0 +1,31 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
export async function requireSession() {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) throw new Error("UNAUTHORIZED");
const session = await prisma.session.findFirst({
where: {
id: sessionId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
});
if (!session) throw new Error("UNAUTHORIZED");
// Optional: update lastSeenAt (useful later)
await prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
return {
sessionId: session.id,
userId: session.userId,
orgId: session.orgId,
};
}

11
lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ["error", "warn"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ["mis.maliountech.com.mx"],
};
export default nextConfig;

470
package-lock.json generated
View File

@@ -8,17 +8,22 @@
"name": "mis-control-tower",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.19.1",
"bcrypt": "^6.0.0",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"prisma": "^6.19.1",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -1226,6 +1231,91 @@
"node": ">=12.4.0"
}
},
"node_modules/@prisma/client": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.1.tgz",
"integrity": "sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.1.tgz",
"integrity": "sha512-bUL/aYkGXLwxVGhJmQMtslLT7KPEfUqmRa919fKI4wQFX4bIFUKiY8Jmio/2waAjjPYrtuDHa7EsNCnJTXxiOw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.18.4",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.1.tgz",
"integrity": "sha512-h1JImhlAd/s5nhY/e9qkAzausWldbeT+e4nZF7A4zjDYBF4BZmKDt4y0jK7EZapqOm1kW7V0e9agV/iFDy3fWw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.1.tgz",
"integrity": "sha512-xy95dNJ7DiPf9IJ3oaVfX785nbFl7oNDzclUF+DIiJw6WdWCvPl0LPU0YqQLsrwv8N64uOQkH391ujo3wSo+Nw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.1",
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"@prisma/fetch-engine": "6.19.1",
"@prisma/get-platform": "6.19.1"
}
},
"node_modules/@prisma/engines-version": {
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.1.tgz",
"integrity": "sha512-mmgcotdaq4VtAHO6keov3db+hqlBzQS6X7tR7dFCbvXjLVTxBYdSJFRWz+dq7F9p6dvWyy1X0v8BlfRixyQK6g==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.1",
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"@prisma/get-platform": "6.19.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.1.tgz",
"integrity": "sha512-zsg44QUiQAnFUyh6Fbt7c9HjMXHwFTqtrgcX7DAZmRgnkPyYT7Sh8Mn8D5PuuDYNtMOYcpLGg576MLfIORsBYw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.1"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1233,6 +1323,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1524,6 +1621,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2416,6 +2523,20 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2474,6 +2595,35 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^16.6.1",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
"jiti": "^2.4.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^1.0.0",
"pkg-types": "^2.2.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "^0.3.5"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2571,6 +2721,32 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2604,6 +2780,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2719,6 +2912,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -2755,6 +2958,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2778,6 +2995,19 @@
"node": ">=0.10.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2793,6 +3023,17 @@
"node": ">= 0.4"
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -2807,6 +3048,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
@@ -3445,6 +3696,36 @@
"node": ">=0.10.0"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^6.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3717,6 +3998,24 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4394,7 +4693,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -4834,6 +5133,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.561.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz",
"integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5029,6 +5337,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -5036,6 +5371,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.2",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": "^14.16.0 || >=16.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5159,6 +5514,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5267,6 +5629,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5286,6 +5662,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5335,6 +5723,32 @@
"node": ">= 0.8.0"
}
},
"node_modules/prisma": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.1.tgz",
"integrity": "sha512-XRfmGzh6gtkc/Vq3LqZJcS2884dQQW3UhPo6jNRoiTW95FFQkXFg8vkYEy6og+Pyv0aY7zRQ7Wn1Cvr56XjhQQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.19.1",
"@prisma/engines": "6.19.1"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5357,6 +5771,23 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5378,6 +5809,17 @@
],
"license": "MIT"
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/react": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
@@ -5406,6 +5848,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6039,6 +6495,16 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6240,7 +6706,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@@ -9,17 +9,22 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/client": "^6.19.1",
"bcrypt": "^6.0.0",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"prisma": "^6.19.1",
"tailwindcss": "^4",
"typescript": "^5"
}

14
prisma.config.ts.bak Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,103 @@
-- CreateTable
CREATE TABLE "Org" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Org_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrgUser" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrgUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Machine" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Machine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Org_slug_key" ON "Org"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "OrgUser_userId_idx" ON "OrgUser"("userId");
-- CreateIndex
CREATE INDEX "OrgUser_orgId_idx" ON "OrgUser"("orgId");
-- CreateIndex
CREATE UNIQUE INDEX "OrgUser_orgId_userId_key" ON "OrgUser"("orgId", "userId");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_orgId_idx" ON "Session"("orgId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE INDEX "Machine_orgId_idx" ON "Machine"("orgId");
-- CreateIndex
CREATE UNIQUE INDEX "Machine_orgId_code_key" ON "Machine"("orgId", "code");
-- AddForeignKey
ALTER TABLE "OrgUser" ADD CONSTRAINT "OrgUser_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgUser" ADD CONSTRAINT "OrgUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Machine" ADD CONSTRAINT "Machine_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,39 @@
/*
Warnings:
- A unique constraint covering the columns `[orgId,name]` on the table `Machine` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `Machine` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "Machine_orgId_code_key";
-- AlterTable
ALTER TABLE "Machine" ADD COLUMN "location" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateTable
CREATE TABLE "MachineHeartbeat" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL,
"message" TEXT,
"ip" TEXT,
"fwVersion" TEXT,
CONSTRAINT "MachineHeartbeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MachineHeartbeat_orgId_machineId_ts_idx" ON "MachineHeartbeat"("orgId", "machineId", "ts");
-- CreateIndex
CREATE UNIQUE INDEX "Machine_orgId_name_key" ON "Machine"("orgId", "name");
-- AddForeignKey
ALTER TABLE "MachineHeartbeat" ADD CONSTRAINT "MachineHeartbeat_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineHeartbeat" ADD CONSTRAINT "MachineHeartbeat_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[apiKey]` on the table `Machine` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Machine" ADD COLUMN "apiKey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Machine_apiKey_key" ON "Machine"("apiKey");

View File

@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "MachineKpiSnapshot" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"workOrderId" TEXT,
"sku" TEXT,
"target" INTEGER,
"good" INTEGER,
"scrap" INTEGER,
"cycleCount" INTEGER,
"goodParts" INTEGER,
"scrapParts" INTEGER,
"cavities" INTEGER,
"cycleTime" DOUBLE PRECISION,
"actualCycle" DOUBLE PRECISION,
"availability" DOUBLE PRECISION,
"performance" DOUBLE PRECISION,
"quality" DOUBLE PRECISION,
"oee" DOUBLE PRECISION,
"trackingEnabled" BOOLEAN,
"productionStarted" BOOLEAN,
CONSTRAINT "MachineKpiSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MachineEvent" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"topic" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"requiresAck" BOOLEAN NOT NULL DEFAULT false,
"title" TEXT NOT NULL,
"description" TEXT,
"data" JSONB,
"workOrderId" TEXT,
"sku" TEXT,
CONSTRAINT "MachineEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MachineKpiSnapshot_orgId_machineId_ts_idx" ON "MachineKpiSnapshot"("orgId", "machineId", "ts");
-- CreateIndex
CREATE INDEX "MachineEvent_orgId_machineId_ts_idx" ON "MachineEvent"("orgId", "machineId", "ts");
-- CreateIndex
CREATE INDEX "MachineEvent_orgId_machineId_eventType_ts_idx" ON "MachineEvent"("orgId", "machineId", "eventType", "ts");
-- AddForeignKey
ALTER TABLE "MachineKpiSnapshot" ADD CONSTRAINT "MachineKpiSnapshot_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineKpiSnapshot" ADD CONSTRAINT "MachineKpiSnapshot_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineEvent" ADD CONSTRAINT "MachineEvent_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineEvent" ADD CONSTRAINT "MachineEvent_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

163
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,163 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Org {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
members OrgUser[]
sessions Session[]
machines Machine[]
heartbeats MachineHeartbeat[]
kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[]
}
model User {
id String @id @default(uuid())
email String @unique
name String?
passwordHash String
isActive Boolean @default(true)
createdAt DateTime @default(now())
orgs OrgUser[]
sessions Session[]
}
model OrgUser {
id String @id @default(uuid())
orgId String
userId String
role String @default("MEMBER") // OWNER | ADMIN | MEMBER
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([orgId, userId])
@@index([userId])
@@index([orgId])
}
model Session {
id String @id @default(uuid()) // cookie value
orgId String
userId String
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
expiresAt DateTime
revokedAt DateTime?
ip String?
userAgent String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([orgId])
@@index([expiresAt])
}
model Machine {
id String @id @default(uuid())
orgId String
name String
apiKey String? @unique
code String?
location String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
heartbeats MachineHeartbeat[]
kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[]
@@unique([orgId, name])
@@index([orgId])
}
model MachineHeartbeat {
id String @id @default(uuid())
orgId String
machineId String
ts DateTime @default(now())
status String
message String?
ip String?
fwVersion String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts])
}
model MachineKpiSnapshot {
id String @id @default(uuid())
orgId String
machineId String
ts DateTime @default(now())
workOrderId String?
sku String?
target Int?
good Int?
scrap Int?
cycleCount Int?
goodParts Int?
scrapParts Int?
cavities Int?
cycleTime Float? // theoretical/target
actualCycle Float? // if you want (optional)
availability Float?
performance Float?
quality Float?
oee Float?
trackingEnabled Boolean?
productionStarted Boolean?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts])
}
model MachineEvent {
id String @id @default(uuid())
orgId String
machineId String
ts DateTime @default(now())
topic String // "anomaly-detected"
eventType String // "slow-cycle"
severity String // "critical"
requiresAck Boolean @default(false)
title String
description String?
// store the raw data blob so we don't lose fields
data Json?
workOrderId String?
sku String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts])
@@index([orgId, machineId, eventType, ts])
}

48
prisma/seed.ts Normal file
View File

@@ -0,0 +1,48 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt";
const prisma = new PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash("admin123", 10);
const org = await prisma.org.upsert({
where: { slug: "maliountech" },
update: {},
create: {
name: "MaliounTech",
slug: "maliountech",
},
});
const user = await prisma.user.upsert({
where: { email: "admin@maliountech.com" },
update: {},
create: {
email: "admin@maliountech.com",
name: "Admin",
passwordHash,
},
});
await prisma.orgUser.upsert({
where: {
orgId_userId: {
orgId: org.id,
userId: user.id,
},
},
update: {},
create: {
orgId: org.id,
userId: user.id,
role: "OWNER",
},
});
console.log("Seeded admin user");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
"baseUrl": ".",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,