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 ( +
+
+ +
{children}
+
+
+ ); +} 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 ( +
+
+

Control Tower

+

Sign in to your organization

+ +
+
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ + {err &&
{err}
} + + + +
(Dev mode) This will be replaced with JWT auth later.
+
+
+
+ ); +} 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 ( -
-
- Next.js logo -
-

- 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,