diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
new file mode 100644
index 0000000..2feb873
--- /dev/null
+++ b/app/(app)/layout.tsx
@@ -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 (don’t 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 (
+
+ );
+}
diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx
new file mode 100644
index 0000000..27d2808
--- /dev/null
+++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx
@@ -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(null);
+ const [events, setEvents] = useState([]);
+ const [error, setError] = useState(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 (
+
+
+
+
+
+ {machine?.name ?? "Machine"}
+
+
+ {statusLabel}
+
+
+
+ {machine?.code ? machine.code : "—"} • {machine?.location ? machine.location : "—"} • Last seen{" "}
+ {hb?.ts ? timeAgo(hb.ts) : "never"}
+
+
+
+
+
+ Back
+
+
+
+
+ {loading &&
Loading…
}
+ {error && !loading && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && (
+ <>
+ {/* KPI cards */}
+
+
+
OEE
+
{fmtPct(kpi?.oee)}
+
Updated {kpi?.ts ? timeAgo(kpi.ts) : "never"}
+
+
+
+
Availability
+
{fmtPct(kpi?.availability)}
+
+
+
+
Performance
+
{fmtPct(kpi?.performance)}
+
+
+
+
Quality
+
{fmtPct(kpi?.quality)}
+
+
+
+ {/* Work order + recent events */}
+
+
+
+
Current Work Order
+
{kpi?.workOrderId ?? "—"}
+
+
+
SKU
+
{kpi?.sku ?? "—"}
+
+
+
+
Target
+
{fmtNum(kpi?.target)}
+
+
+
Good
+
{fmtNum(kpi?.good)}
+
+
+
Scrap
+
{fmtNum(kpi?.scrap)}
+
+
+
+
+ Cycle target: {kpi?.cycleTime ? `${kpi.cycleTime}s` : "—"}
+
+
+
+
+
+
Recent Events
+
{events.length} shown
+
+
+ {events.length === 0 ? (
+
No events yet.
+ ) : (
+
+ {events.map((e) => (
+
+
+
+
+
+ {e.severity.toUpperCase()}
+
+
+ {e.eventType}
+
+ {e.requiresAck && (
+
+ ACK
+
+ )}
+
+
+
{e.title}
+ {e.description && (
+
{e.description}
+ )}
+
+
+
{timeAgo(e.ts)}
+
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/app/(app)/machines/[machineId]/page.tsx b/app/(app)/machines/[machineId]/page.tsx
new file mode 100644
index 0000000..42d3ef5
--- /dev/null
+++ b/app/(app)/machines/[machineId]/page.tsx
@@ -0,0 +1,5 @@
+import MachineDetailClient from "./MachineDetailClient";
+
+export default function Page() {
+ return ;
+}
diff --git a/app/(app)/machines/page.tsx b/app/(app)/machines/page.tsx
new file mode 100644
index 0000000..40a14f8
--- /dev/null
+++ b/app/(app)/machines/page.tsx
@@ -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([]);
+ 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 (
+
+
+
+
Machines
+
Select a machine to view live KPIs.
+
+
+
+ Back to Overview
+
+
+
+ {loading &&
Loading machines…
}
+
+ {!loading && machines.length === 0 && (
+
No machines found for this org.
+ )}
+
+
+ {(!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 (
+
+
+
+
{m.name}
+
+ {m.code ? m.code : "—"} • Last seen {lastSeen}
+
+
+
+
+ {statusLabel}
+
+
+
+
Status
+
+ {offline ? "No heartbeat" : hb?.message ?? "OK"}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts
new file mode 100644
index 0000000..b0e8681
--- /dev/null
+++ b/app/api/ingest/event/route.ts
@@ -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 });
+}
diff --git a/app/api/ingest/heartbeat/route.ts b/app/api/ingest/heartbeat/route.ts
new file mode 100644
index 0000000..73b3e00
--- /dev/null
+++ b/app/api/ingest/heartbeat/route.ts
@@ -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 });
+}
diff --git a/app/api/ingest/kpi/route.ts b/app/api/ingest/kpi/route.ts
new file mode 100644
index 0000000..db66645
--- /dev/null
+++ b/app/api/ingest/kpi/route.ts
@@ -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 });
+}
diff --git a/app/api/login/route.ts b/app/api/login/route.ts
new file mode 100644
index 0000000..a32b822
--- /dev/null
+++ b/app/api/login/route.ts
@@ -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;
+}
diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts
new file mode 100644
index 0000000..6682c25
--- /dev/null
+++ b/app/api/logout/route.ts
@@ -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;
+}
diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts
new file mode 100644
index 0000000..b0837df
--- /dev/null
+++ b/app/api/machines/[machineId]/route.ts
@@ -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,
+ });
+}
diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts
new file mode 100644
index 0000000..e0024ea
--- /dev/null
+++ b/app/api/machines/route.ts
@@ -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 });
+}
diff --git a/app/api/me/route.ts b/app/api/me/route.ts
new file mode 100644
index 0000000..a8f7368
--- /dev/null
+++ b/app/api/me/route.ts
@@ -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 });
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index f7fa87e..e57d067 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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 (
-
+
{children}
diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx
new file mode 100644
index 0000000..a4eb062
--- /dev/null
+++ b/app/login/LoginForm.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..cdf8413
--- /dev/null
+++ b/app/login/page.tsx
@@ -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 ; // ✅ actually render it
+
+}
diff --git a/app/logout/route.ts b/app/logout/route.ts
new file mode 100644
index 0000000..22ba4e9
--- /dev/null
+++ b/app/logout/route.ts
@@ -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;
+}
diff --git a/app/mdares.code-workspace b/app/mdares.code-workspace
new file mode 100644
index 0000000..f1693c9
--- /dev/null
+++ b/app/mdares.code-workspace
@@ -0,0 +1,11 @@
+{
+ "folders": [
+ {
+ "path": "../.."
+ },
+ {
+ "path": "../../../../etc/nginx/sites-available"
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
index 295f8fd..1dcb7d8 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,65 +1,5 @@
-import Image from "next/image";
+import { redirect } from "next/navigation";
export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+ redirect("/machines");
}
diff --git a/components/auth/RequireAuth.tsx b/components/auth/RequireAuth.tsx
new file mode 100644
index 0000000..346e168
--- /dev/null
+++ b/components/auth/RequireAuth.tsx
@@ -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 (
+
+ Loading…
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..b4d448f
--- /dev/null
+++ b/components/layout/Sidebar.tsx
@@ -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 (
+
+ );
+}
diff --git a/components/layout/Topbar.tsx b/components/layout/Topbar.tsx
new file mode 100644
index 0000000..9446bf2
--- /dev/null
+++ b/components/layout/Topbar.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+export function Topbar({ title }: { title: string }) {
+ return (
+
+
{title}
+
+ Live • (mock for now)
+
+
+ );
+}
diff --git a/cookies.txt b/cookies.txt
new file mode 100644
index 0000000..4d659a6
--- /dev/null
+++ b/cookies.txt
@@ -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
diff --git a/lib/auth/requireSession.ts b/lib/auth/requireSession.ts
new file mode 100644
index 0000000..91299f0
--- /dev/null
+++ b/lib/auth/requireSession.ts
@@ -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,
+ };
+}
diff --git a/lib/prisma.ts b/lib/prisma.ts
new file mode 100644
index 0000000..09b2fb9
--- /dev/null
+++ b/lib/prisma.ts
@@ -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;
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..f1c55fb 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ allowedDevOrigins: ["mis.maliountech.com.mx"],
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 637a1cc..ae5d943 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7bb52ed..f30a4a2 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/prisma.config.ts.bak b/prisma.config.ts.bak
new file mode 100644
index 0000000..9c5e959
--- /dev/null
+++ b/prisma.config.ts.bak
@@ -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"),
+ },
+});
diff --git a/prisma/migrations/20251216173800_init_auth/migration.sql b/prisma/migrations/20251216173800_init_auth/migration.sql
new file mode 100644
index 0000000..4db3f8b
--- /dev/null
+++ b/prisma/migrations/20251216173800_init_auth/migration.sql
@@ -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;
diff --git a/prisma/migrations/20251217000852_machines_and_heartbeats/migration.sql b/prisma/migrations/20251217000852_machines_and_heartbeats/migration.sql
new file mode 100644
index 0000000..f764b91
--- /dev/null
+++ b/prisma/migrations/20251217000852_machines_and_heartbeats/migration.sql
@@ -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;
diff --git a/prisma/migrations/20251217001526_machine_api_key/migration.sql b/prisma/migrations/20251217001526_machine_api_key/migration.sql
new file mode 100644
index 0000000..739c306
--- /dev/null
+++ b/prisma/migrations/20251217001526_machine_api_key/migration.sql
@@ -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");
diff --git a/prisma/migrations/20251217012912_kpi_snapshots_and_events/migration.sql b/prisma/migrations/20251217012912_kpi_snapshots_and_events/migration.sql
new file mode 100644
index 0000000..92445ac
--- /dev/null
+++ b/prisma/migrations/20251217012912_kpi_snapshots_and_events/migration.sql
@@ -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;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..044d57c
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -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"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..c17c1ee
--- /dev/null
+++ b/prisma/schema.prisma
@@ -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])
+}
diff --git a/prisma/seed.ts b/prisma/seed.ts
new file mode 100644
index 0000000..aa6812e
--- /dev/null
+++ b/prisma/seed.ts
@@ -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());
diff --git a/tsconfig.json b/tsconfig.json
index 3a13f90..843261a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
+ "baseUrl": ".",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,