This commit is contained in:
jason
2026-04-20 15:49:01 -05:00
parent 381a31d607
commit b98837a72c
46 changed files with 8883 additions and 37 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npm --version)",
"Bash(npm install *)",
"Bash(DATABASE_URL=\"file:./dev.db\" npx prisma migrate dev --name init --skip-seed)",
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npx tsc --noEmit)",
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)"
]
}
}
+26
View File
@@ -0,0 +1,26 @@
node_modules
.next
.git
.github
.vscode
.idea
coverage
*.log
.env
.env.local
.env.*.local
data/
uploads/
*.db
*.db-journal
*.sqlite
README.md
AGENTS.md
DEPLOYMENT-PROFILE.md
INSTALL.md
PROJECT-PROFILE-WORKBOOK.md
ROUTING-EXAMPLES.md
SKILLS.md
hubs/
skills/
docs/
+27
View File
@@ -0,0 +1,27 @@
# Path to SQLite database (file: URL for Prisma)
DATABASE_URL="file:./data/app.db"
# Directory for uploaded files (STEP, PDF, DXF, SVG, images)
UPLOAD_DIR="./data/uploads"
# Public URL where this app is reachable (used in QR code payloads)
# In production set to your subdomain, e.g. https://mrp.example.com
APP_URL="http://localhost:3000"
# Secret used to sign session cookies and QR tokens. MUST be set in production.
# Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
APP_SECRET="dev-only-change-me-dev-only-change-me-dev-only-change-me"
# Session TTL in hours
ADMIN_SESSION_HOURS=8
OPERATOR_SESSION_HOURS=12
# Bootstrap admin created on first boot if no admin exists.
# After first login, change this password via the admin UI.
BOOTSTRAP_ADMIN_EMAIL="admin@example.com"
BOOTSTRAP_ADMIN_PASSWORD="changeme"
BOOTSTRAP_ADMIN_NAME="Administrator"
# PIN lockout policy
PIN_MAX_ATTEMPTS=5
PIN_LOCKOUT_MINUTES=15
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.sh text eol=lf
Dockerfile text eol=lf
+46
View File
@@ -0,0 +1,46 @@
# deps
node_modules/
.pnp
.pnp.js
# testing
coverage/
# next.js
.next/
out/
next-env.d.ts
# production
build/
dist/
# misc
.DS_Store
*.pem
Thumbs.db
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env
.env
.env.local
.env.*.local
# data (dev)
data/
*.db
*.db-journal
*.sqlite
*.sqlite-journal
# uploads (dev)
uploads/
# editor
.vscode/
.idea/
*.swp
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1.7
# --- deps ------------------------------------------------------------------
FROM node:20-alpine AS deps
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
# --- build -----------------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate
RUN npm run build
# --- runner ----------------------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat tini
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
DATABASE_URL="file:/data/app.db" \
UPLOAD_DIR="/data/uploads"
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./next.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /data/uploads /data/backups && chown -R nextjs:nodejs /data
USER nextjs
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
CMD ["npm", "run", "start"]
+61 -37
View File
@@ -1,47 +1,71 @@
# Drop-In Agent Instruction Suite # MRP QR Code System
This repository is a portable markdown instruction pack for coding agents. A single-container, self-hosted Manufacturing Resource Planning (MRP) app built around printable QR-coded traveler cards. Designed for small fabrication shops running on an Unraid server with phone-based operators.
Copy these files into another repository to give the agent: ## Status
- a root `AGENTS.md` entrypoint,
- a central skill index,
- category hubs for routing,
- specialized skill files for common software, docs, UX, marketing, and ideation tasks.
## Structure **Step 1 of the build plan** is in this commit: repo scaffold, Prisma schema, Docker build, and authentication (admin email + password; operator name + 4-digit PIN with 12h device session). Everything downstream — project / assembly / part CRUD, QR generation, operator scan flow, PDF travelers, fasteners & POs, dashboards, STEP viewer, QC — is planned but not yet implemented.
- `AGENTS.md` - base instructions and routing rules See [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md) for the sequenced roadmap.
- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults
- `INSTALL.md` - copy and customization guide for other repositories
- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults
- `SKILLS.md` - canonical skill index
- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples
- `hubs/` - category-level routing guides
- `skills/` - specialized reusable skill files
## Design Goals ## Core concepts
- Plain markdown only - **Project → Assembly → Part → Operation.** Each operation is one shop-floor step (cut, bend, rivet, weld, …) and gets its own printable QR card.
- Cross-agent portability - **Single claim.** Only one operator can hold an operation at a time; other scans show it as in-progress.
- Implementation-first defaults - **Two roles.** Admins (email + password) plan the work. Operators (PIN) execute it from their phones.
- On-demand skill loading instead of loading everything every session - **Files.** STEP / PDF / DXF / SVG upload per part; STEP viewer will render in-browser so phones don't need a CAD app.
- Context-efficient routing for large skill libraries - **Purchasing.** Fasteners roll up across a project into PO drafts.
- Prefilled deployment defaults without per-install questioning - **Online only.** The server lives in the shop; no offline/PWA queueing.
- Repo-local instructions take precedence over this bundle
## Intended Workflow ## Stack
1. The agent reads `AGENTS.md`. - Next.js 15 (App Router) + React 19 + TypeScript
2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in. - Prisma + SQLite (file-backed, on a single `/data` volume)
3. The agent checks `SKILLS.md`. - Tailwind CSS 4
4. The agent opens only the relevant hub and skill files for the task. - bcryptjs for password / PIN hashing
5. The agent combines multiple skills when the task spans several domains. - Zod for input validation and environment parsing
## Core Categories ## Local development
- Software development Prerequisites: Node 20+, npm.
- Debugging
- Documentation ```bash
- UI/UX cp .env.example .env
- Marketing # edit .env and set APP_SECRET to at least 32 random chars
- Brainstorming npm install
npx prisma migrate dev --name init
npm run db:seed # creates the bootstrap admin from .env
npm run dev
```
Visit http://localhost:3000 and sign in as the bootstrap admin.
## Docker / Unraid deployment
See [`docs/DEPLOY.md`](docs/DEPLOY.md). In short:
```bash
docker compose up -d --build
```
The container runs `prisma migrate deploy` on every start and creates a bootstrap admin on first boot if none exists. All persistent state lives in the `/data` volume (`app.db` + `uploads/` + `backups/`).
## Environment
All env vars are documented in [`.env.example`](.env.example). `APP_SECRET` must be set and at least 32 characters in production.
## Project layout
```
app/ Next.js routes (UI + /api/*)
components/ Shared React components
lib/ env, prisma, auth, session, password, audit, request helpers
prisma/ schema.prisma + migrations/
scripts/ seed.ts and future ops scripts
docker/ entrypoint.sh
docs/ Project docs (DEPLOY, BUILD-PLAN, ARCHITECTURE)
```
## Not in this repo
The top-level `AGENTS.md`, `SKILLS.md`, `hubs/`, and `skills/` directories are the coding-agent instruction suite this project was started from. They are reference material for AI assistants and are not shipped in the Docker image (they are listed in `.dockerignore`).
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
import { requireAdmin } from "@/lib/auth";
import LogoutButton from "@/components/LogoutButton";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await requireAdmin();
return (
<div className="min-h-dvh flex flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<Link href="/admin" className="font-semibold tracking-tight">
MRP <span className="text-slate-400 font-normal">· Admin</span>
</Link>
<nav className="flex gap-4 text-sm text-slate-600">
<Link href="/admin" className="hover:text-slate-900">Dashboard</Link>
<Link href="/admin/projects" className="hover:text-slate-900">Projects</Link>
<Link href="/admin/machines" className="hover:text-slate-900">Machines</Link>
<Link href="/admin/operations" className="hover:text-slate-900">Operation templates</Link>
<Link href="/admin/users" className="hover:text-slate-900">Users</Link>
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
<span className="text-slate-500">{user.name}</span>
<LogoutButton />
</div>
</div>
</header>
<main className="flex-1">{children}</main>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
export default function AdminDashboardPage() {
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="text-slate-500 mt-1">
Project planning, machines, operations, and users will appear here as each area is built.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card title="Projects" desc="Plan work: assemblies, parts, and operations." />
<Card title="Machines" desc="Manage shop-floor equipment." />
<Card title="Operation templates" desc="Reusable step recipes." />
<Card title="Fasteners & POs" desc="Aggregate BOM, generate purchase orders." />
<Card title="Users" desc="Admins and operator PIN accounts." />
<Card title="Audit log" desc="Who did what, when." />
</div>
</div>
);
}
function Card({ title, desc }: { title: string; desc: string }) {
return (
<div className="rounded-xl bg-white border border-slate-200 p-5">
<h2 className="font-medium">{title}</h2>
<p className="text-sm text-slate-500 mt-1">{desc}</p>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
import { createSession } from "@/lib/session";
import { audit } from "@/lib/audit";
import { clientIp, userAgent } from "@/lib/request";
const Body = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(req: NextRequest) {
const parsed = Body.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 400 });
}
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
const ip = clientIp(req);
const ua = userAgent(req);
if (!user || user.role !== "admin" || !user.active || !user.passwordHash) {
await audit({ action: "login_failed", entity: "User", entityId: user?.id, ipAddress: ip });
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const ok = await verifyPassword(password, user.passwordHash);
if (!ok) {
await audit({ action: "login_failed", entity: "User", entityId: user.id, ipAddress: ip });
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
await createSession({
userId: user.id,
role: "admin",
userAgent: ua,
ipAddress: ip,
});
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date(), failedAttempts: 0, lockedUntil: null },
});
await audit({ actorId: user.id, action: "login", entity: "User", entityId: user.id, ipAddress: ip });
return NextResponse.json({ ok: true, redirect: "/admin" });
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { destroyCurrentSession, getSessionUser } from "@/lib/session";
import { audit } from "@/lib/audit";
export async function POST() {
const user = await getSessionUser();
await destroyCurrentSession();
if (user) {
await audit({ actorId: user.id, action: "logout", entity: "User", entityId: user.id });
}
return NextResponse.json({ ok: true });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { verifyPin, isValidPin } from "@/lib/password";
import { createSession } from "@/lib/session";
import { env } from "@/lib/env";
import { audit } from "@/lib/audit";
import { clientIp, userAgent } from "@/lib/request";
const Body = z.object({
operatorId: z.string().min(1),
pin: z.string().regex(/^\d{4}$/, "PIN must be 4 digits"),
});
export async function POST(req: NextRequest) {
const parsed = Body.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid PIN" }, { status: 400 });
}
const { operatorId, pin } = parsed.data;
if (!isValidPin(pin)) {
return NextResponse.json({ error: "Invalid PIN" }, { status: 400 });
}
const ip = clientIp(req);
const ua = userAgent(req);
const user = await prisma.user.findUnique({ where: { id: operatorId } });
if (!user || user.role !== "operator" || !user.active || !user.pinHash) {
await audit({ action: "login_failed", entity: "User", entityId: user?.id, ipAddress: ip });
return NextResponse.json({ error: "Invalid PIN" }, { status: 401 });
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const mins = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
return NextResponse.json(
{ error: `Account locked. Try again in ${mins} minute${mins === 1 ? "" : "s"}.` },
{ status: 423 },
);
}
const ok = await verifyPin(pin, user.pinHash);
if (!ok) {
const attempts = user.failedAttempts + 1;
const shouldLock = attempts >= env.PIN_MAX_ATTEMPTS;
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: shouldLock ? 0 : attempts,
lockedUntil: shouldLock ? new Date(Date.now() + env.PIN_LOCKOUT_MINUTES * 60_000) : null,
},
});
await audit({ action: "login_failed", entity: "User", entityId: user.id, ipAddress: ip });
return NextResponse.json(
{
error: shouldLock
? `Too many attempts. Locked for ${env.PIN_LOCKOUT_MINUTES} minutes.`
: "Invalid PIN",
},
{ status: shouldLock ? 423 : 401 },
);
}
await createSession({
userId: user.id,
role: "operator",
userAgent: ua,
ipAddress: ip,
});
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date(), failedAttempts: 0, lockedUntil: null },
});
await audit({ actorId: user.id, action: "login", entity: "User", entityId: user.id, ipAddress: ip });
return NextResponse.json({ ok: true, redirect: "/op" });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ ok: true, ts: new Date().toISOString() });
} catch (err) {
return NextResponse.json(
{ ok: false, error: err instanceof Error ? err.message : "unknown" },
{ status: 503 },
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Public-by-design: returns the list of active operators so the login tile
// grid can render. Contains no secrets (no email, no hashes).
export async function GET() {
const operators = await prisma.user.findMany({
where: { role: "operator", active: true },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return NextResponse.json({ operators });
}
+14
View File
@@ -0,0 +1,14 @@
@import "tailwindcss";
:root {
color-scheme: light;
}
html,
body {
height: 100%;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "MRP",
description: "QR-code driven manufacturing resource planning.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#0f172a",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function AdminLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const res = await fetch("/api/auth/admin/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error ?? "Sign-in failed");
return;
}
router.push(data.redirect ?? "/admin");
router.refresh();
} catch {
setError("Network error");
} finally {
setBusy(false);
}
}
return (
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
<form
onSubmit={onSubmit}
className="w-full max-w-sm space-y-5 bg-white rounded-2xl border border-slate-200 p-6 shadow-sm"
>
<div>
<h1 className="text-2xl font-semibold">Admin sign-in</h1>
<p className="text-slate-500 text-sm mt-1">Use your email and password.</p>
</div>
<label className="block">
<span className="block text-sm font-medium text-slate-700 mb-1">Email</span>
<input
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-base outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
/>
</label>
<label className="block">
<span className="block text-sm font-medium text-slate-700 mb-1">Password</span>
<input
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-base outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
/>
</label>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-slate-900 text-white py-2.5 font-medium disabled:opacity-60 hover:bg-slate-800 transition"
>
{busy ? "Signing in…" : "Sign in"}
</button>
<div className="text-center">
<Link href="/login" className="text-sm text-slate-500 hover:text-slate-900">
Back
</Link>
</div>
</form>
</main>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
interface Operator {
id: string;
name: string;
}
export default function OperatorLoginPage() {
const router = useRouter();
const [operators, setOperators] = useState<Operator[] | null>(null);
const [selected, setSelected] = useState<Operator | null>(null);
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
fetch("/api/operators")
.then((r) => r.json())
.then((d) => setOperators(d.operators ?? []))
.catch(() => setOperators([]));
}, []);
function pressKey(k: string) {
setError(null);
if (k === "back") {
setPin((p) => p.slice(0, -1));
return;
}
if (k === "clear") {
setPin("");
return;
}
setPin((p) => (p.length >= 4 ? p : p + k));
}
async function submit() {
if (!selected || pin.length !== 4) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/auth/operator/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ operatorId: selected.id, pin }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error ?? "Sign-in failed");
setPin("");
return;
}
router.push(data.redirect ?? "/op");
router.refresh();
} catch {
setError("Network error");
} finally {
setBusy(false);
}
}
useEffect(() => {
if (pin.length === 4 && !busy) {
void submit();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pin]);
if (operators === null) {
return (
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
<p className="text-slate-500">Loading</p>
</main>
);
}
if (!selected) {
return (
<main className="min-h-dvh p-6 bg-slate-50">
<div className="mx-auto max-w-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-semibold">Who are you?</h1>
<p className="text-slate-500 mt-1">Tap your name to sign in.</p>
</div>
{operators.length === 0 ? (
<div className="rounded-xl bg-white border border-slate-200 p-6 text-center">
<p className="text-slate-700">No operators exist yet.</p>
<p className="text-slate-500 text-sm mt-1">Ask an admin to create your account.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{operators.map((op) => (
<button
key={op.id}
onClick={() => setSelected(op)}
className="rounded-xl bg-white border border-slate-200 px-4 py-5 text-lg font-medium hover:border-slate-900 hover:shadow-sm transition"
>
{op.name}
</button>
))}
</div>
)}
<div className="text-center mt-8">
<Link href="/login" className="text-sm text-slate-500 hover:text-slate-900">
Back
</Link>
</div>
</div>
</main>
);
}
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
return (
<main className="min-h-dvh p-6 bg-slate-50">
<div className="mx-auto max-w-sm">
<div className="text-center mb-4">
<button
onClick={() => {
setSelected(null);
setPin("");
setError(null);
}}
className="text-sm text-slate-500 hover:text-slate-900"
>
Not {selected.name}?
</button>
</div>
<div className="rounded-2xl bg-white border border-slate-200 p-6 shadow-sm">
<h1 className="text-xl font-semibold text-center">Hi, {selected.name}</h1>
<p className="text-slate-500 text-sm text-center mt-1">Enter your 4-digit PIN</p>
<div className="flex justify-center gap-3 my-6">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={`w-4 h-4 rounded-full ${pin.length > i ? "bg-slate-900" : "bg-slate-200"}`}
/>
))}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4 text-center">
{error}
</p>
)}
<div className="grid grid-cols-3 gap-2">
{keys.map((k) => {
const label = k === "back" ? "⌫" : k === "clear" ? "C" : k;
return (
<button
key={k}
onClick={() => pressKey(k)}
disabled={busy}
className="h-14 rounded-lg bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-xl font-medium transition disabled:opacity-60"
>
{label}
</button>
);
})}
</div>
</div>
</div>
</main>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Link from "next/link";
export default function LoginChoicePage() {
return (
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
<div className="w-full max-w-md space-y-6">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">MRP</h1>
<p className="text-slate-500 mt-1">Sign in to continue</p>
</div>
<div className="grid gap-3">
<Link
href="/login/operator"
className="block rounded-xl bg-slate-900 text-white px-5 py-4 text-center font-medium shadow-sm hover:bg-slate-800 transition"
>
I&apos;m an operator
</Link>
<Link
href="/login/admin"
className="block rounded-xl bg-white border border-slate-200 px-5 py-4 text-center font-medium text-slate-900 hover:bg-slate-100 transition"
>
Admin sign-in
</Link>
</div>
</div>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from "next/link";
import { requireOperator } from "@/lib/auth";
import LogoutButton from "@/components/LogoutButton";
export default async function OperatorLayout({ children }: { children: React.ReactNode }) {
const user = await requireOperator();
return (
<div className="min-h-dvh flex flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto max-w-3xl px-4 py-3 flex items-center gap-3">
<Link href="/op" className="font-semibold tracking-tight">
MRP
</Link>
<span className="ml-auto text-sm text-slate-500">{user.name}</span>
<LogoutButton />
</div>
</header>
<main className="flex-1">{children}</main>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export default function OperatorHomePage() {
return (
<div className="mx-auto max-w-3xl px-4 py-10 text-center space-y-6">
<div>
<h1 className="text-2xl font-semibold">Scan a traveler QR code</h1>
<p className="text-slate-500 mt-2">
Use your phone camera to scan the QR on a step card. It will open the step here so you can
start, log time, and close out.
</p>
</div>
<div className="rounded-xl bg-white border border-slate-200 p-6 text-slate-500 text-sm">
<p>Your active steps will appear here once you claim them.</p>
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
export default async function IndexPage() {
const user = await getCurrentUser();
if (!user) redirect("/login");
redirect(user.role === "admin" ? "/admin" : "/op");
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LogoutButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onClick() {
setBusy(true);
try {
await fetch("/api/auth/logout", { method: "POST" });
} finally {
router.push("/login");
router.refresh();
}
}
return (
<button
onClick={onClick}
disabled={busy}
className="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100 disabled:opacity-60"
>
{busy ? "…" : "Sign out"}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
services:
mrp:
build: .
image: mrp-qrcode:local
container_name: mrp-qrcode
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: "file:/data/app.db"
UPLOAD_DIR: "/data/uploads"
APP_URL: "${APP_URL:-http://localhost:3000}"
APP_SECRET: "${APP_SECRET:?APP_SECRET must be set}"
ADMIN_SESSION_HOURS: "${ADMIN_SESSION_HOURS:-8}"
OPERATOR_SESSION_HOURS: "${OPERATOR_SESSION_HOURS:-12}"
BOOTSTRAP_ADMIN_EMAIL: "${BOOTSTRAP_ADMIN_EMAIL:-admin@example.com}"
BOOTSTRAP_ADMIN_PASSWORD: "${BOOTSTRAP_ADMIN_PASSWORD:-changeme}"
BOOTSTRAP_ADMIN_NAME: "${BOOTSTRAP_ADMIN_NAME:-Administrator}"
PIN_MAX_ATTEMPTS: "${PIN_MAX_ATTEMPTS:-5}"
PIN_LOCKOUT_MINUTES: "${PIN_LOCKOUT_MINUTES:-15}"
volumes:
- mrp-data:/data
volumes:
mrp-data:
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -e
echo "[mrp] ensuring data dirs at /data ..."
mkdir -p /data/uploads /data/backups
SCHEMA=/app/prisma/schema.prisma
if [ -d /app/prisma/migrations ] && [ -n "$(ls -A /app/prisma/migrations 2>/dev/null)" ]; then
echo "[mrp] running prisma migrate deploy ..."
npx prisma migrate deploy --schema="$SCHEMA"
else
echo "[mrp] no migrations directory found; using prisma db push for first-boot ..."
npx prisma db push --skip-generate --schema="$SCHEMA"
fi
echo "[mrp] seeding bootstrap admin if needed ..."
npx tsx scripts/seed.ts || echo "[mrp] seed script reported non-zero, continuing"
echo "[mrp] starting server on :${PORT:-3000} ..."
exec "$@"
+103
View File
@@ -0,0 +1,103 @@
# Architecture
## Runtime
- **Single container.** Node 20 (alpine) running Next.js in production mode. One process serves both the UI and the REST API.
- **Single volume (`/data`).** SQLite database (`app.db`), user uploads (`uploads/`), and backups (`backups/`) live side-by-side so the whole application state is one directory to back up.
- **Stateless app, stateful volume.** Nothing in the image needs to survive a rebuild except the schema, which is applied to `/data/app.db` at container start via `prisma migrate deploy`.
- **No background workers yet.** All work happens inline on a request. PDF generation and STEP thumbnailing will stay request-scoped until throughput forces otherwise.
## Request flow
```
Browser ──TLS── Reverse proxy ── HTTP ── Next.js (app+api) ── Prisma ── SQLite
└─► /data/uploads
```
- Reverse proxy terminates TLS (handled by Unraid SWAG / NPM / Caddy / Traefik etc.).
- The container listens on `:3000` with `HOSTNAME=0.0.0.0`.
- Health probe: `GET /api/health` runs `SELECT 1` against SQLite.
## Authentication
Two roles, no external identity providers.
### Admin
- Email + password. Password hashed with bcrypt (cost 12).
- Longer session default (`ADMIN_SESSION_HOURS`, default 8h).
### Operator
- Name + 4-digit PIN. PIN hashed with bcrypt (cost 12).
- Device session default 12h (`OPERATOR_SESSION_HOURS`).
- 5 failed attempts lock the account for 15 minutes (configurable).
- The operator login UI is a tile grid of active operator names, followed by a numeric keypad; no email/username typing on a phone.
### Sessions
- Server-side, backed by the `Session` table.
- Cookie `mrp_session` holds a random 32-byte token (base64url). Only the SHA-256 of the token is stored server-side, so a DB leak cannot hand out live sessions.
- `httpOnly`, `sameSite=lax`, `secure` in production, path `/`, `expires` matched to DB TTL.
- `lastSeenAt` is touched at most once per minute to avoid write amplification.
- `purgeExpiredSessions()` is exposed for a future cron.
## Data model
See [`prisma/schema.prisma`](../prisma/schema.prisma) for the canonical definition. Highlights:
- **Project → Assembly → Part → Operation.** `@@unique([assemblyId, code])` and `@@unique([partId, sequence])` keep the hierarchy tidy.
- **Operation.claimedByUserId** enforces single-claim at the app layer; the status/claim fields are indexed for dashboard queries.
- **TimeLog** is separate from Operation so a step can have multiple start/stop sessions over time.
- **QCRecord** supports both `inline` (checkbox on the step card) and `dedicated` (a QC operation type) via its `kind` field.
- **FileAsset** is a single table for all uploads; parts and purchase orders reference by nullable foreign key.
- **AuditLog** captures mutations with before/after snapshots as JSON strings (SQLite does not have a native Json type).
Enums are stored as strings rather than Prisma enums because SQLite has no enum support; validation is done at the Zod layer.
## API
- Planned surface: `/api/v1/*` (REST + JSON). The auth, operator list, and health endpoints in the current commit are the internal surface and will move under `/api/v1` when step 10 formalizes the public API.
- Every mutation writes an `AuditLog` row with actor, IP, and before/after payloads.
- Zod schemas validate request bodies; they will also drive OpenAPI generation in step 10.
## Front-end structure
- `app/login/*` — public login pages (chooser, admin form, operator tile grid + keypad).
- `app/admin/*` — admin shell (requires admin session, top-nav layout).
- `app/op/*` — operator shell (requires operator session, mobile-friendly layout).
- `app/api/*` — route handlers.
- `components/` — shared client components (initially just `LogoutButton`).
Every protected layout calls `requireAdmin()` or `requireOperator()` in its server component, which redirects unauthenticated requests to `/login`. There is intentionally no edge middleware because Prisma does not run on the Edge runtime.
## Files on disk
```
/data
├── app.db SQLite database
├── app.db-wal, app.db-shm SQLite WAL sidecars (when enabled)
├── backups/ point-in-time SQLite snapshots (future)
└── uploads/ user uploads, organized by content hash
├── step/
├── pdf/
├── dxf/
├── svg/
└── image/
```
Upload handling (step 2) will write content-addressed files: hash the incoming bytes, store at `uploads/<kind>/<sha256>.<ext>`, and reference via `FileAsset.path`. Duplicate uploads collapse to one file on disk.
## Build-time vs. run-time
- `npm run build` runs `prisma generate` and then `next build`. No database required.
- `prisma migrate deploy` runs **at container start**, not at build time, so the image is portable across environments.
- The bootstrap admin seed runs at start too, idempotently (it does nothing if an admin already exists).
## Security posture
- Passwords and PINs: bcrypt with cost 12.
- Session tokens: cryptographically random, never stored plaintext.
- Cookies: `httpOnly`, `sameSite=lax`, `secure` in prod.
- Input validation: Zod on every route.
- Audit log for every mutation and every login attempt (success or failure).
- PIN lockout: 5 attempts / 15 min (configurable). Addresses the small 4-digit search space.
- `APP_SECRET` is not yet used to sign QR tokens — that arrives in step 3. It is validated at boot so deploy-time misconfiguration fails loudly.
+28
View File
@@ -0,0 +1,28 @@
# Build Plan
The roadmap agreed at project kickoff. Each step is committed separately so the app can be deployed and used at any point in the sequence.
| # | Step | Status |
| - | ---- | ------ |
| 1 | Repo scaffold, Dockerfile, Prisma schema, auth (admin email/password, operator PIN, 12h session) | **In progress** |
| 2 | Admin CRUD: Machines, Operation Templates, Projects → Assemblies → Parts, file uploads | planned |
| 3 | Operation authoring (template or ad-hoc) + QR token generation | planned |
| 4 | Operator scan flow: claim → start → units/notes → QC prompt → close | planned |
| 5 | PDF generation: per-operation card + per-part cover sheet | planned |
| 6 | Fasteners + PO (PDF + lifecycle: draft → sent → partial → received) | planned |
| 7 | Admin dashboard (WIP, overdue, plan-vs-actual) + audit log viewer | planned |
| 8 | In-browser STEP viewer + server-side thumbnails | planned |
| 9 | QC records (inline checkboxes + dedicated QC operation type) | planned |
| 10 | OpenAPI docs at `/api/docs`, nightly SQLite backup script | planned |
## Locked design decisions
- **Hierarchy:** Project → Assembly → Part → Operation.
- **QR granularity:** one QR per Operation; each step prints its own card.
- **Claim model:** an Operation locks to one operator on Start; other scans of an in-progress operation show a read-only view noting who holds it.
- **Operators can hold multiple operations at once** (across different parts).
- **Purchase orders:** PDF generation + lifecycle states (`draft → sent → partial → received → cancelled`).
- **No offline mode.** The app assumes the shop LAN is up.
- **Language:** English only in the UI.
- **Persistence:** SQLite in a single mounted `/data` volume; migrations via Prisma.
- **Integrations:** none for now; REST API is versioned (`/api/v1/*`) and fully documented for future use.
+98
View File
@@ -0,0 +1,98 @@
# Deploying on Unraid
The MRP app is a single Docker container that stores everything (SQLite database + uploaded files + backups) under a single `/data` volume.
## 1. Prepare environment
Pick a host directory on your Unraid array (example: `/mnt/user/appdata/mrp-qrcode/`). This will hold the database and uploaded files, and will survive container upgrades.
Generate a strong app secret:
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
```
## 2. docker-compose (recommended)
Create `.env` next to `docker-compose.yml`:
```env
APP_URL=https://mrp.yourdomain.tld
APP_SECRET=<paste-the-secret-from-step-1>
BOOTSTRAP_ADMIN_EMAIL=you@yourdomain.tld
BOOTSTRAP_ADMIN_PASSWORD=<a-strong-password>
BOOTSTRAP_ADMIN_NAME=Your Name
```
Then:
```bash
docker compose up -d --build
```
The container will:
1. Create `/data/uploads` and `/data/backups` inside the volume.
2. Run `prisma migrate deploy`.
3. Create the bootstrap admin if no admin exists.
4. Start the web server on port 3000.
## 3. Bind the `/data` volume to host storage (Unraid)
If you prefer a host bind mount over the named volume, replace the `volumes:` block in `docker-compose.yml` with:
```yaml
volumes:
- /mnt/user/appdata/mrp-qrcode:/data
```
Make sure the host directory is owned by UID 1001 (the `nextjs` user inside the container):
```bash
mkdir -p /mnt/user/appdata/mrp-qrcode
chown -R 1001:1001 /mnt/user/appdata/mrp-qrcode
```
## 4. Reverse proxy / subdomain
Point your reverse proxy (SWAG, Nginx Proxy Manager, Caddy, Traefik — whatever is already on your Unraid) at `http://<container-ip>:3000` and terminate TLS there.
`APP_URL` must match the externally reachable URL — it is embedded in QR code payloads and used for absolute links. If operators scan a card and land on `http://10.x.x.x:3000`, their phone probably cannot reach that IP; always set `APP_URL` to the public subdomain.
## 5. Backups
The container does not yet run automatic backups. Until step 9 of the build plan ships, back up `/data` with your Unraid backup strategy:
- `/data/app.db` (SQLite file)
- `/data/app.db-wal` and `/data/app.db-shm` if present (SQLite WAL sidecars)
- `/data/uploads/`
A safe way to snapshot a live SQLite DB is:
```bash
docker exec mrp-qrcode sqlite3 /data/app.db ".backup '/data/backups/app-$(date +%F).db'"
```
## 6. Upgrades
```bash
git pull
docker compose up -d --build
```
Migrations run automatically on start. Before major upgrades, snapshot the DB as above.
## 7. First-login checklist
1. Sign in at `/login/admin` with the bootstrap credentials.
2. Change your password (admin settings — shipping in a later step).
3. Create your operators (each gets a name and a 4-digit PIN).
4. Add your machines.
5. Create operation templates for repetitive steps.
6. Create your first project.
## Troubleshooting
- **`APP_SECRET must be at least 32 chars`** — the container refuses to start without one. Regenerate as shown in step 1.
- **`migrations/` is empty** — run `npx prisma migrate dev --name init` locally once, commit the generated `prisma/migrations/` directory, rebuild the image.
- **Healthcheck failing** — `docker logs mrp-qrcode` and check DB permissions on `/data`.
+17
View File
@@ -0,0 +1,17 @@
import { FlatCompat } from "@eslint/eslintrc";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
const config = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: ["node_modules/**", ".next/**", "prisma/migrations/**"],
},
];
export default config;
+29
View File
@@ -0,0 +1,29 @@
import { prisma } from "@/lib/prisma";
export interface AuditInput {
actorId?: string | null;
action: string;
entity: string;
entityId?: string | null;
before?: unknown;
after?: unknown;
ipAddress?: string | null;
}
export async function audit(input: AuditInput): Promise<void> {
try {
await prisma.auditLog.create({
data: {
actorId: input.actorId ?? null,
action: input.action,
entity: input.entity,
entityId: input.entityId ?? null,
before: input.before ? JSON.stringify(input.before) : null,
after: input.after ? JSON.stringify(input.after) : null,
ipAddress: input.ipAddress ?? null,
},
});
} catch (err) {
console.error("[audit] failed to record:", err);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getSessionUser, type SessionUser } from "@/lib/session";
export async function getCurrentUser(): Promise<SessionUser | null> {
return getSessionUser();
}
export async function requireUser(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/login");
return user;
}
export async function requireAdmin(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.role !== "admin") redirect("/");
return user;
}
export async function requireOperator(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.role !== "operator") redirect("/");
return user;
}
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().min(1),
UPLOAD_DIR: z.string().default("./data/uploads"),
APP_URL: z.string().url().default("http://localhost:3000"),
APP_SECRET: z.string().min(32, "APP_SECRET must be at least 32 chars"),
ADMIN_SESSION_HOURS: z.coerce.number().int().positive().default(8),
OPERATOR_SESSION_HOURS: z.coerce.number().int().positive().default(12),
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(1).optional(),
BOOTSTRAP_ADMIN_NAME: z.string().default("Administrator"),
PIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5),
PIN_LOCKOUT_MINUTES: z.coerce.number().int().positive().default(15),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export type Env = z.infer<typeof EnvSchema>;
function load(): Env {
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
return parsed.data;
}
export const env = load();
+29
View File
@@ -0,0 +1,29 @@
import bcrypt from "bcryptjs";
const ADMIN_ROUNDS = 12;
const PIN_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
if (!password || password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
return bcrypt.hash(password, ADMIN_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function isValidPin(pin: string): boolean {
return /^\d{4}$/.test(pin);
}
export async function hashPin(pin: string): Promise<string> {
if (!isValidPin(pin)) throw new Error("PIN must be exactly 4 digits");
return bcrypt.hash(pin, PIN_ROUNDS);
}
export async function verifyPin(pin: string, hash: string): Promise<boolean> {
if (!isValidPin(pin)) return false;
return bcrypt.compare(pin, hash);
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
declare global {
var __prismaClient: PrismaClient | undefined;
}
export const prisma =
globalThis.__prismaClient ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalThis.__prismaClient = prisma;
}
+14
View File
@@ -0,0 +1,14 @@
import type { NextRequest } from "next/server";
export function clientIp(req: NextRequest | Request): string | null {
const headers = "headers" in req ? req.headers : new Headers();
const fwd = headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
const real = headers.get("x-real-ip");
if (real) return real.trim();
return null;
}
export function userAgent(req: NextRequest | Request): string | null {
return req.headers.get("user-agent");
}
+113
View File
@@ -0,0 +1,113 @@
import { randomBytes, createHash } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
export const SESSION_COOKIE = "mrp_session";
export type Role = "admin" | "operator";
export interface SessionUser {
id: string;
role: Role;
name: string;
email: string | null;
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function newToken(): { token: string; hash: string } {
const token = randomBytes(32).toString("base64url");
return { token, hash: sha256(token) };
}
function ttlForRole(role: Role): number {
const hours = role === "admin" ? env.ADMIN_SESSION_HOURS : env.OPERATOR_SESSION_HOURS;
return hours * 60 * 60 * 1000;
}
export interface CreateSessionInput {
userId: string;
role: Role;
userAgent?: string | null;
ipAddress?: string | null;
deviceLabel?: string | null;
}
export async function createSession(input: CreateSessionInput): Promise<{ token: string; expiresAt: Date }> {
const { token, hash } = newToken();
const expiresAt = new Date(Date.now() + ttlForRole(input.role));
await prisma.session.create({
data: {
userId: input.userId,
tokenHash: hash,
expiresAt,
userAgent: input.userAgent ?? null,
ipAddress: input.ipAddress ?? null,
deviceLabel: input.deviceLabel ?? null,
},
});
const jar = await cookies();
jar.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
expires: expiresAt,
});
return { token, expiresAt };
}
export async function getSessionUser(): Promise<SessionUser | null> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE)?.value;
if (!token) return null;
const hash = sha256(token);
const session = await prisma.session.findUnique({
where: { tokenHash: hash },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
return null;
}
if (!session.user.active) return null;
// touch lastSeenAt at most once per minute to limit write load
const stale = Date.now() - session.lastSeenAt.getTime() > 60_000;
if (stale) {
prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
}
return {
id: session.user.id,
role: session.user.role as Role,
name: session.user.name,
email: session.user.email,
};
}
export async function destroyCurrentSession(): Promise<void> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session.deleteMany({ where: { tokenHash: sha256(token) } });
}
jar.delete(SESSION_COOKIE);
}
export async function purgeExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}
+13
View File
@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
experimental: {
serverActions: {
bodySizeLimit: "50mb",
},
},
};
export default nextConfig;
+6737
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "mrp-qrcode",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"db:seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"postcss": "^8.5.0",
"prisma": "^5.22.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,289 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"role" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"passwordHash" TEXT,
"pinHash" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" DATETIME,
"lastLoginAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"deviceLabel" TEXT,
"userAgent" TEXT,
"ipAddress" TEXT,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Machine" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "OperationTemplate" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"machineId" TEXT,
"defaultSettings" TEXT,
"defaultInstructions" TEXT,
"qcRequired" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "OperationTemplate_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"customerCode" TEXT,
"dueDate" DATETIME,
"status" TEXT NOT NULL DEFAULT 'planning',
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Assembly" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"qty" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Assembly_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"assemblyId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"material" TEXT,
"qty" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT,
"stepFileId" TEXT,
"drawingFileId" TEXT,
"cutFileId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_assemblyId_fkey" FOREIGN KEY ("assemblyId") REFERENCES "Assembly" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Part_stepFileId_fkey" FOREIGN KEY ("stepFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_drawingFileId_fkey" FOREIGN KEY ("drawingFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_cutFileId_fkey" FOREIGN KEY ("cutFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Operation" (
"id" TEXT NOT NULL PRIMARY KEY,
"partId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"templateId" TEXT,
"name" TEXT NOT NULL,
"machineId" TEXT,
"settings" TEXT,
"materialNotes" TEXT,
"instructions" TEXT,
"qcRequired" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'pending',
"qrToken" TEXT NOT NULL,
"claimedByUserId" TEXT,
"claimedAt" DATETIME,
"completedAt" DATETIME,
"plannedMinutes" INTEGER,
"plannedUnits" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Operation_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Operation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "OperationTemplate" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Operation_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Operation_claimedByUserId_fkey" FOREIGN KEY ("claimedByUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TimeLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"operationId" TEXT NOT NULL,
"operatorId" TEXT NOT NULL,
"startedAt" DATETIME NOT NULL,
"endedAt" DATETIME,
"unitsProcessed" INTEGER,
"note" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TimeLog_operationId_fkey" FOREIGN KEY ("operationId") REFERENCES "Operation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TimeLog_operatorId_fkey" FOREIGN KEY ("operatorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "QCRecord" (
"id" TEXT NOT NULL PRIMARY KEY,
"operationId" TEXT NOT NULL,
"operatorId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"measurements" TEXT,
"passed" BOOLEAN NOT NULL,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QCRecord_operationId_fkey" FOREIGN KEY ("operationId") REFERENCES "Operation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "QCRecord_operatorId_fkey" FOREIGN KEY ("operatorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Fastener" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"partNumber" TEXT NOT NULL,
"description" TEXT NOT NULL,
"qty" INTEGER NOT NULL,
"supplier" TEXT,
"unitCost" REAL,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Fastener_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PurchaseOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"vendor" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"sentAt" DATETIME,
"receivedAt" DATETIME,
"notes" TEXT,
"pdfFileId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrder_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseOrder_pdfFileId_fkey" FOREIGN KEY ("pdfFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "POLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"poId" TEXT NOT NULL,
"fastenerId" TEXT NOT NULL,
"qty" INTEGER NOT NULL,
"unitCost" REAL,
"receivedQty" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "POLine_poId_fkey" FOREIGN KEY ("poId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "POLine_fastenerId_fkey" FOREIGN KEY ("fastenerId") REFERENCES "Fastener" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "FileAsset" (
"id" TEXT NOT NULL PRIMARY KEY,
"kind" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"path" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"mimeType" TEXT,
"sha256" TEXT NOT NULL,
"uploadedBy" TEXT,
"uploadedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"actorId" TEXT,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT,
"before" TEXT,
"after" TEXT,
"ipAddress" TEXT,
"at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_active_idx" ON "User"("role", "active");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Machine_name_key" ON "Machine"("name");
-- CreateIndex
CREATE UNIQUE INDEX "OperationTemplate_name_key" ON "OperationTemplate"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Project_code_key" ON "Project"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Assembly_projectId_code_key" ON "Assembly"("projectId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Part_assemblyId_code_key" ON "Part"("assemblyId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Operation_qrToken_key" ON "Operation"("qrToken");
-- CreateIndex
CREATE INDEX "Operation_status_idx" ON "Operation"("status");
-- CreateIndex
CREATE INDEX "Operation_claimedByUserId_idx" ON "Operation"("claimedByUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Operation_partId_sequence_key" ON "Operation"("partId", "sequence");
-- CreateIndex
CREATE INDEX "TimeLog_operationId_idx" ON "TimeLog"("operationId");
-- CreateIndex
CREATE INDEX "TimeLog_operatorId_idx" ON "TimeLog"("operatorId");
-- CreateIndex
CREATE INDEX "QCRecord_operationId_idx" ON "QCRecord"("operationId");
-- CreateIndex
CREATE INDEX "Fastener_projectId_idx" ON "Fastener"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "FileAsset_sha256_key" ON "FileAsset"("sha256");
-- CreateIndex
CREATE INDEX "AuditLog_entity_entityId_idx" ON "AuditLog"("entity", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_at_idx" ON "AuditLog"("at");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
+308
View File
@@ -0,0 +1,308 @@
// MRP QR Code system — database schema
// Provider: SQLite (enums stored as string constants; JSON stored as strings)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Users & sessions
// ---------------------------------------------------------------------------
/// role: "admin" | "operator"
/// admins authenticate with email + password
/// operators authenticate with name + 4-digit PIN
model User {
id String @id @default(cuid())
role String
name String
email String? @unique
passwordHash String?
pinHash String?
active Boolean @default(true)
failedAttempts Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
timeLogs TimeLog[]
qcRecords QCRecord[]
auditLogs AuditLog[] @relation("ActorLogs")
claimedOps Operation[] @relation("ClaimedBy")
@@index([role, active])
}
model Session {
id String @id @default(cuid())
userId String
tokenHash String @unique
deviceLabel String?
userAgent String?
ipAddress String?
expiresAt DateTime
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
// ---------------------------------------------------------------------------
// Shop floor: machines + operation templates
// ---------------------------------------------------------------------------
model Machine {
id String @id @default(cuid())
name String @unique
kind String // free-form: NCT_PUNCH | PRESS_BRAKE | RIVET | WELD | OTHER
location String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templates OperationTemplate[]
operations Operation[]
}
/// Reusable recipe an admin can pick from when authoring an operation on a part.
model OperationTemplate {
id String @id @default(cuid())
name String @unique
machineId String?
defaultSettings String? // JSON-encoded key/value
defaultInstructions String?
qcRequired Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
machine Machine? @relation(fields: [machineId], references: [id], onDelete: SetNull)
operations Operation[]
}
// ---------------------------------------------------------------------------
// Project hierarchy: Project → Assembly → Part → Operation
// ---------------------------------------------------------------------------
model Project {
id String @id @default(cuid())
code String @unique
name String
customerCode String?
dueDate DateTime?
status String @default("planning") // planning | in_progress | completed | cancelled
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assemblies Assembly[]
fasteners Fastener[]
purchaseOrders PurchaseOrder[]
}
model Assembly {
id String @id @default(cuid())
projectId String
code String
name String
qty Int @default(1)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
parts Part[]
@@unique([projectId, code])
}
model Part {
id String @id @default(cuid())
assemblyId String
code String
name String
material String?
qty Int @default(1)
notes String?
stepFileId String?
drawingFileId String?
cutFileId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assembly Assembly @relation(fields: [assemblyId], references: [id], onDelete: Cascade)
stepFile FileAsset? @relation("PartStep", fields: [stepFileId], references: [id], onDelete: SetNull)
drawingFile FileAsset? @relation("PartDrawing", fields: [drawingFileId], references: [id], onDelete: SetNull)
cutFile FileAsset? @relation("PartCut", fields: [cutFileId], references: [id], onDelete: SetNull)
operations Operation[]
@@unique([assemblyId, code])
}
/// A single shop-floor step on a specific part. Each has its own QR traveler card.
/// Only one operator may hold the claim at a time (claimedByUserId set).
model Operation {
id String @id @default(cuid())
partId String
sequence Int
templateId String?
name String
machineId String?
settings String? // JSON
materialNotes String?
instructions String?
qcRequired Boolean @default(false)
status String @default("pending") // pending | in_progress | completed
qrToken String @unique
claimedByUserId String?
claimedAt DateTime?
completedAt DateTime?
plannedMinutes Int?
plannedUnits Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
template OperationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
machine Machine? @relation(fields: [machineId], references: [id], onDelete: SetNull)
claimedBy User? @relation("ClaimedBy", fields: [claimedByUserId], references: [id], onDelete: SetNull)
timeLogs TimeLog[]
qcRecords QCRecord[]
@@unique([partId, sequence])
@@index([status])
@@index([claimedByUserId])
}
model TimeLog {
id String @id @default(cuid())
operationId String
operatorId String
startedAt DateTime
endedAt DateTime?
unitsProcessed Int?
note String?
createdAt DateTime @default(now())
operation Operation @relation(fields: [operationId], references: [id], onDelete: Cascade)
operator User @relation(fields: [operatorId], references: [id])
@@index([operationId])
@@index([operatorId])
}
model QCRecord {
id String @id @default(cuid())
operationId String
operatorId String
kind String // "inline" | "dedicated"
measurements String? // JSON
passed Boolean
notes String?
createdAt DateTime @default(now())
operation Operation @relation(fields: [operationId], references: [id], onDelete: Cascade)
operator User @relation(fields: [operatorId], references: [id])
@@index([operationId])
}
// ---------------------------------------------------------------------------
// Purchasing: fasteners + POs
// ---------------------------------------------------------------------------
model Fastener {
id String @id @default(cuid())
projectId String
partNumber String
description String
qty Int
supplier String?
unitCost Float?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
poLines POLine[]
@@index([projectId])
}
model PurchaseOrder {
id String @id @default(cuid())
projectId String
vendor String
status String @default("draft") // draft | sent | partial | received | cancelled
sentAt DateTime?
receivedAt DateTime?
notes String?
pdfFileId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
pdfFile FileAsset? @relation("PoPdf", fields: [pdfFileId], references: [id], onDelete: SetNull)
lines POLine[]
}
model POLine {
id String @id @default(cuid())
poId String
fastenerId String
qty Int
unitCost Float?
receivedQty Int @default(0)
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
fastener Fastener @relation(fields: [fastenerId], references: [id])
}
// ---------------------------------------------------------------------------
// Files & audit
// ---------------------------------------------------------------------------
model FileAsset {
id String @id @default(cuid())
kind String // step | pdf | dxf | svg | png | jpg | other
originalName String
path String // relative to UPLOAD_DIR
sizeBytes Int
mimeType String?
sha256 String @unique
uploadedBy String?
uploadedAt DateTime @default(now())
partStep Part[] @relation("PartStep")
partDrawing Part[] @relation("PartDrawing")
partCut Part[] @relation("PartCut")
poPdfs PurchaseOrder[] @relation("PoPdf")
}
model AuditLog {
id String @id @default(cuid())
actorId String?
action String // e.g. "create", "update", "delete", "login", "claim_op", "close_op"
entity String
entityId String?
before String? // JSON
after String? // JSON
ipAddress String?
at DateTime @default(now())
actor User? @relation("ActorLogs", fields: [actorId], references: [id], onDelete: SetNull)
@@index([entity, entityId])
@@index([at])
}
+46
View File
@@ -0,0 +1,46 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
const existingAdmin = await prisma.user.findFirst({ where: { role: "admin" } });
if (existingAdmin) {
console.log(`[seed] admin already exists (${existingAdmin.email ?? existingAdmin.id}); nothing to do`);
return;
}
const email = process.env.BOOTSTRAP_ADMIN_EMAIL;
const password = process.env.BOOTSTRAP_ADMIN_PASSWORD;
const name = process.env.BOOTSTRAP_ADMIN_NAME ?? "Administrator";
if (!email || !password) {
console.warn(
"[seed] BOOTSTRAP_ADMIN_EMAIL/PASSWORD not set — no admin created. Set them and rerun.",
);
return;
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
role: "admin",
name,
email,
passwordHash,
active: true,
},
});
console.log(`[seed] created bootstrap admin ${user.email} (id=${user.id})`);
console.log("[seed] IMPORTANT: change this password after first login.");
}
main()
.catch((err) => {
console.error("[seed] failed:", err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long