stage 1
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
+46
@@ -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
@@ -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"]
|
||||
@@ -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:
|
||||
- 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.
|
||||
## Status
|
||||
|
||||
## 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
|
||||
- `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
|
||||
See [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md) for the sequenced roadmap.
|
||||
|
||||
## Design Goals
|
||||
## Core concepts
|
||||
|
||||
- Plain markdown only
|
||||
- Cross-agent portability
|
||||
- Implementation-first defaults
|
||||
- On-demand skill loading instead of loading everything every session
|
||||
- Context-efficient routing for large skill libraries
|
||||
- Prefilled deployment defaults without per-install questioning
|
||||
- Repo-local instructions take precedence over this bundle
|
||||
- **Project → Assembly → Part → Operation.** Each operation is one shop-floor step (cut, bend, rivet, weld, …) and gets its own printable QR card.
|
||||
- **Single claim.** Only one operator can hold an operation at a time; other scans show it as in-progress.
|
||||
- **Two roles.** Admins (email + password) plan the work. Operators (PIN) execute it from their phones.
|
||||
- **Files.** STEP / PDF / DXF / SVG upload per part; STEP viewer will render in-browser so phones don't need a CAD app.
|
||||
- **Purchasing.** Fasteners roll up across a project into PO drafts.
|
||||
- **Online only.** The server lives in the shop; no offline/PWA queueing.
|
||||
|
||||
## Intended Workflow
|
||||
## Stack
|
||||
|
||||
1. The agent reads `AGENTS.md`.
|
||||
2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in.
|
||||
3. The agent checks `SKILLS.md`.
|
||||
4. The agent opens only the relevant hub and skill files for the task.
|
||||
5. The agent combines multiple skills when the task spans several domains.
|
||||
- Next.js 15 (App Router) + React 19 + TypeScript
|
||||
- Prisma + SQLite (file-backed, on a single `/data` volume)
|
||||
- Tailwind CSS 4
|
||||
- bcryptjs for password / PIN hashing
|
||||
- Zod for input validation and environment parsing
|
||||
|
||||
## Core Categories
|
||||
## Local development
|
||||
|
||||
- Software development
|
||||
- Debugging
|
||||
- Documentation
|
||||
- UI/UX
|
||||
- Marketing
|
||||
- Brainstorming
|
||||
Prerequisites: Node 20+, npm.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env and set APP_SECRET to at least 32 random chars
|
||||
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`).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 antialiased;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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 "$@"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6737
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user