phase 2 and 3

This commit is contained in:
jason
2026-04-21 08:56:51 -05:00
parent b98837a72c
commit d79aaf6ef8
42 changed files with 4962 additions and 19 deletions
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse, type NextRequest } from "next/server";
import { ZodError, type ZodSchema } from "zod";
import { getSessionUser, type Role, type SessionUser } from "@/lib/session";
import { clientIp } from "@/lib/request";
export class ApiError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message);
}
}
export function errorResponse(err: unknown): NextResponse {
if (err instanceof ApiError) {
return NextResponse.json({ error: err.message, code: err.code }, { status: err.status });
}
if (err instanceof ZodError) {
return NextResponse.json(
{
error: "Validation failed",
code: "validation_failed",
issues: err.issues.map((i) => ({ path: i.path, message: i.message })),
},
{ status: 400 },
);
}
if (
err &&
typeof err === "object" &&
"code" in err &&
typeof (err as { code: unknown }).code === "string" &&
((err as { code: string }).code === "P2002" || (err as { code: string }).code === "P2003")
) {
const p = err as { code: string; meta?: { target?: unknown } };
const msg =
p.code === "P2002"
? `Duplicate value${p.meta?.target ? ` on ${JSON.stringify(p.meta.target)}` : ""}`
: "Foreign key constraint failed";
return NextResponse.json(
{ error: msg, code: p.code === "P2002" ? "duplicate" : "fk_violation" },
{ status: 409 },
);
}
console.error("[api] unhandled error:", err);
return NextResponse.json(
{ error: "Internal server error", code: "internal" },
{ status: 500 },
);
}
export async function requireRole(role: Role): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) throw new ApiError(401, "unauthenticated", "Sign in required");
if (user.role !== role) throw new ApiError(403, "forbidden", "Not allowed");
return user;
}
export async function parseJson<T>(req: NextRequest, schema: ZodSchema<T>): Promise<T> {
let raw: unknown;
try {
raw = await req.json();
} catch {
throw new ApiError(400, "invalid_json", "Expected JSON body");
}
return schema.parse(raw);
}
export function actorContext(req: NextRequest, user: SessionUser) {
return { actorId: user.id, ipAddress: clientIp(req) };
}
export function ok<T>(data: T, init?: ResponseInit): NextResponse {
return NextResponse.json(data, init);
}
+29
View File
@@ -0,0 +1,29 @@
export interface ApiFailure {
error: string;
code?: string;
issues?: Array<{ path: unknown; message: string }>;
}
export class ApiClientError extends Error {
constructor(public status: number, public code: string | undefined, message: string) {
super(message);
}
}
export async function apiFetch<T>(
input: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !headers.has("content-type") && !(init.body instanceof FormData)) {
headers.set("content-type", "application/json");
}
const res = await fetch(input, { ...init, headers });
const text = await res.text();
const data = text ? (JSON.parse(text) as unknown) : undefined;
if (!res.ok) {
const fail = (data ?? { error: res.statusText }) as ApiFailure;
throw new ApiClientError(res.status, fail.code, fail.error);
}
return data as T;
}
+18 -1
View File
@@ -18,7 +18,24 @@ const EnvSchema = z.object({
export type Env = z.infer<typeof EnvSchema>;
function load(): Env {
const parsed = EnvSchema.safeParse(process.env);
// During `next build` page-data collection the route modules are evaluated
// without real secrets — fall back to safe placeholders so the build can
// emit the module graph. Real runtime still re-validates at request time.
const isBuildPhase =
process.env.NEXT_PHASE === "phase-production-build" ||
process.env.NEXT_BUILD === "true";
const source = isBuildPhase
? {
...process.env,
DATABASE_URL: process.env.DATABASE_URL ?? "file:./data/build-placeholder.db",
APP_SECRET:
process.env.APP_SECRET ??
"build-time-placeholder-secret-please-override-at-runtime",
}
: process.env;
const parsed = EnvSchema.safeParse(source);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
+162
View File
@@ -0,0 +1,162 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile, readFile, unlink, access } from "node:fs/promises";
import { join, extname, resolve } from "node:path";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
export const FILE_KINDS = ["step", "pdf", "dxf", "svg", "png", "jpg", "other"] as const;
export type FileKind = (typeof FILE_KINDS)[number];
const EXT_TO_KIND: Record<string, FileKind> = {
".step": "step",
".stp": "step",
".pdf": "pdf",
".dxf": "dxf",
".svg": "svg",
".png": "png",
".jpg": "jpg",
".jpeg": "jpg",
};
const MAX_SIZE = 100 * 1024 * 1024; // 100 MB per file
export function classifyKind(filename: string, mimeType?: string | null): FileKind {
const ext = extname(filename).toLowerCase();
if (EXT_TO_KIND[ext]) return EXT_TO_KIND[ext];
if (mimeType?.startsWith("image/")) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return "jpg";
if (mimeType.includes("svg")) return "svg";
}
if (mimeType === "application/pdf") return "pdf";
return "other";
}
function uploadRoot(): string {
return resolve(env.UPLOAD_DIR);
}
export function storagePathFor(sha256: string, kind: FileKind, originalName: string): {
relative: string;
absolute: string;
} {
const ext = extname(originalName).toLowerCase() || `.${kind}`;
const shard = sha256.slice(0, 2);
const relative = join(kind, shard, `${sha256}${ext}`);
return {
relative,
absolute: join(uploadRoot(), relative),
};
}
export async function fileExistsOnDisk(absolutePath: string): Promise<boolean> {
try {
await access(absolutePath);
return true;
} catch {
return false;
}
}
export interface SavedFile {
id: string;
kind: FileKind;
sha256: string;
path: string;
sizeBytes: number;
originalName: string;
mimeType: string | null;
deduped: boolean;
}
export async function saveUploadedFile(
file: File,
uploadedBy: string | null,
): Promise<SavedFile> {
if (file.size === 0) throw new Error("Empty file");
if (file.size > MAX_SIZE) throw new Error(`File too large (max ${MAX_SIZE / 1024 / 1024}MB)`);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const sha256 = createHash("sha256").update(buffer).digest("hex");
const kind = classifyKind(file.name, file.type || null);
const existing = await prisma.fileAsset.findUnique({ where: { sha256 } });
if (existing) {
return {
id: existing.id,
kind: existing.kind as FileKind,
sha256: existing.sha256,
path: existing.path,
sizeBytes: existing.sizeBytes,
originalName: existing.originalName,
mimeType: existing.mimeType,
deduped: true,
};
}
const { relative, absolute } = storagePathFor(sha256, kind, file.name);
await mkdir(join(absolute, ".."), { recursive: true });
await writeFile(absolute, buffer);
const asset = await prisma.fileAsset.create({
data: {
kind,
originalName: file.name,
path: relative,
sizeBytes: file.size,
mimeType: file.type || null,
sha256,
uploadedBy,
},
});
return {
id: asset.id,
kind: asset.kind as FileKind,
sha256: asset.sha256,
path: asset.path,
sizeBytes: asset.sizeBytes,
originalName: asset.originalName,
mimeType: asset.mimeType,
deduped: false,
};
}
export async function readFileBytes(relative: string): Promise<Buffer> {
const absolute = resolve(uploadRoot(), relative);
const root = uploadRoot();
if (!absolute.startsWith(root + "/") && !absolute.startsWith(root + "\\") && absolute !== root) {
throw new Error("Invalid file path");
}
return readFile(absolute);
}
export async function deleteFileFromDisk(relative: string): Promise<void> {
const absolute = resolve(uploadRoot(), relative);
const root = uploadRoot();
if (!absolute.startsWith(root + "/") && !absolute.startsWith(root + "\\") && absolute !== root) {
throw new Error("Invalid file path");
}
await unlink(absolute).catch(() => undefined);
}
export function mimeForKind(kind: FileKind, originalMime: string | null): string {
if (originalMime) return originalMime;
switch (kind) {
case "pdf":
return "application/pdf";
case "png":
return "image/png";
case "jpg":
return "image/jpeg";
case "svg":
return "image/svg+xml";
case "step":
return "application/step";
case "dxf":
return "application/dxf";
default:
return "application/octet-stream";
}
}
+14
View File
@@ -0,0 +1,14 @@
import { randomBytes } from "node:crypto";
/**
* Operation QR tokens are opaque, URL-safe, high-entropy identifiers
* printed on traveler cards. 24 bytes = 192 bits of entropy, encoded
* as base64url -> 32 characters.
*/
export function generateQrToken(): string {
return randomBytes(24)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
+237
View File
@@ -0,0 +1,237 @@
import { z } from "zod";
// ---- shared --------------------------------------------------------------
const NonEmpty = z.string().trim().min(1, "Required").max(200);
const Code = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9._\-/]+$/, "Use letters, digits, . _ - /");
const OptionalText = z
.string()
.trim()
.max(5000)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional();
const JsonString = z
.string()
.max(10_000)
.refine(
(s) => {
if (s.length === 0) return true;
try {
JSON.parse(s);
return true;
} catch {
return false;
}
},
{ message: "Must be valid JSON" },
)
.transform((s) => (s.length === 0 ? null : s))
.nullable()
.optional();
const Pin = z.string().regex(/^\d{4}$/, "PIN must be exactly 4 digits");
// ---- users ---------------------------------------------------------------
export const CreateAdminSchema = z.object({
role: z.literal("admin"),
name: NonEmpty,
email: z.string().email().max(200),
password: z.string().min(8).max(200),
});
export const CreateOperatorSchema = z.object({
role: z.literal("operator"),
name: NonEmpty,
pin: Pin,
});
export const CreateUserSchema = z.discriminatedUnion("role", [
CreateAdminSchema,
CreateOperatorSchema,
]);
export const UpdateUserSchema = z
.object({
name: NonEmpty.optional(),
active: z.boolean().optional(),
email: z.string().email().max(200).optional(),
password: z.string().min(8).max(200).optional(),
pin: Pin.optional(),
})
.strict();
// ---- machines ------------------------------------------------------------
export const MachineKinds = [
"NCT_PUNCH",
"PRESS_BRAKE",
"RIVET",
"WELD",
"LASER",
"SHEAR",
"ASSEMBLY",
"OTHER",
] as const;
export const CreateMachineSchema = z.object({
name: NonEmpty,
kind: z.enum(MachineKinds),
location: OptionalText,
notes: OptionalText,
});
export const UpdateMachineSchema = z
.object({
name: NonEmpty.optional(),
kind: z.enum(MachineKinds).optional(),
location: OptionalText,
notes: OptionalText,
active: z.boolean().optional(),
})
.strict();
// ---- operation templates -------------------------------------------------
export const CreateTemplateSchema = z.object({
name: NonEmpty,
machineId: z.string().min(1).nullable().optional(),
defaultSettings: JsonString,
defaultInstructions: OptionalText,
qcRequired: z.boolean().default(false),
});
export const UpdateTemplateSchema = z
.object({
name: NonEmpty.optional(),
machineId: z.string().min(1).nullable().optional(),
defaultSettings: JsonString,
defaultInstructions: OptionalText,
qcRequired: z.boolean().optional(),
active: z.boolean().optional(),
})
.strict();
// ---- projects ------------------------------------------------------------
export const ProjectStatuses = ["planning", "in_progress", "completed", "cancelled"] as const;
export const CreateProjectSchema = z.object({
code: Code,
name: NonEmpty,
customerCode: z
.string()
.trim()
.max(64)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
dueDate: z.coerce.date().nullable().optional(),
notes: OptionalText,
});
export const UpdateProjectSchema = z
.object({
code: Code.optional(),
name: NonEmpty.optional(),
customerCode: z
.string()
.trim()
.max(64)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
dueDate: z.coerce.date().nullable().optional(),
status: z.enum(ProjectStatuses).optional(),
notes: OptionalText,
})
.strict();
// ---- assemblies / parts --------------------------------------------------
export const CreateAssemblySchema = z.object({
code: Code,
name: NonEmpty,
qty: z.coerce.number().int().positive().max(100000).default(1),
notes: OptionalText,
});
export const UpdateAssemblySchema = z
.object({
code: Code.optional(),
name: NonEmpty.optional(),
qty: z.coerce.number().int().positive().max(100000).optional(),
notes: OptionalText,
})
.strict();
export const CreatePartSchema = z.object({
code: Code,
name: NonEmpty,
material: z
.string()
.trim()
.max(120)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
qty: z.coerce.number().int().positive().max(100000).default(1),
notes: OptionalText,
});
export const UpdatePartSchema = z
.object({
code: Code.optional(),
name: NonEmpty.optional(),
material: z
.string()
.trim()
.max(120)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
qty: z.coerce.number().int().positive().max(100000).optional(),
notes: OptionalText,
stepFileId: z.string().min(1).nullable().optional(),
drawingFileId: z.string().min(1).nullable().optional(),
cutFileId: z.string().min(1).nullable().optional(),
})
.strict();
// ---- operations ---------------------------------------------------------
export const OperationStatuses = ["pending", "in_progress", "completed"] as const;
export const CreateOperationSchema = z.object({
templateId: z.string().min(1).nullable().optional(),
name: NonEmpty,
machineId: z.string().min(1).nullable().optional(),
settings: JsonString,
materialNotes: OptionalText,
instructions: OptionalText,
qcRequired: z.boolean().default(false),
plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(),
plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(),
sequence: z.coerce.number().int().positive().max(10000).optional(),
});
export const UpdateOperationSchema = z
.object({
templateId: z.string().min(1).nullable().optional(),
name: NonEmpty.optional(),
machineId: z.string().min(1).nullable().optional(),
settings: JsonString,
materialNotes: OptionalText,
instructions: OptionalText,
qcRequired: z.boolean().optional(),
plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(),
plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(),
sequence: z.coerce.number().int().positive().max(10000).optional(),
status: z.enum(OperationStatuses).optional(),
})
.strict();
export const ReorderOperationsSchema = z.object({
order: z.array(z.string().min(1)).min(1),
});