phase 2 and 3
This commit is contained in:
+73
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
Reference in New Issue
Block a user