v0.13.0 — Inventory (HomeBox): durable items that nest
The first inventory vertical on the shared spine. Things you own — tools, gear, equipment — each living in a Location or inside another Item. - Item model (kind DURABLE|CONSUMABLE; consumable fields present but unused so Pantry needs no migration), self-nesting via parentItemId (toolbox holds drill, with a cycle guard), value/warranty/serial/brand/qty, qrSlug, photo - Label (m2m) + ItemAttachment (photos/manuals/receipts) + ItemLog (maintenance/repair/move/dispose) history; Task.itemId - API-first: /api/v1/items (CRUD), labels, logs, multipart attachments — the UI consumes the same scoped endpoints. items service shared with the coming MCP - Inventory pages: tree grouped by Location with items nested inside items; item detail with warranty status, photos, contents, history; add/edit/delete, log, photo upload. Nav entry. Backup/restore cover the new tables - Pure value.ts (warrantyStatus, totalValue) with tests; verified end-to-end against a running server (nest, cycle-guard 400, log 201) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
192
src/app/(dashboard)/inventory/[id]/page.tsx
Normal file
192
src/app/(dashboard)/inventory/[id]/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
|
||||
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
|
||||
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
|
||||
import { DeleteItemButton } from "@/components/inventory/delete-item-button";
|
||||
import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
|
||||
import { AttachmentUpload } from "@/components/inventory/attachment-upload";
|
||||
|
||||
function toDateInput(d: Date | null): string {
|
||||
return d ? new Date(d).toISOString().split("T")[0] : "";
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
const item = await db.item.findUnique({ where: { id: params.id }, select: { name: true } });
|
||||
return { title: item?.name ?? "Item" };
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params }: { params: { id: string } }) {
|
||||
const [item, locations, allItems, labels] = await Promise.all([
|
||||
db.item.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
location: { select: { id: true, name: true } },
|
||||
parentItem: { select: { id: true, name: true } },
|
||||
labels: { include: { label: true } },
|
||||
attachments: { orderBy: { createdAt: "asc" } },
|
||||
logs: { orderBy: { date: "desc" } },
|
||||
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.item.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.label.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!item || !item.active) notFound();
|
||||
|
||||
const wStatus = warrantyStatus(item.warrantyExpiry, new Date());
|
||||
const photos = item.attachments.filter((a) => a.kind === "PHOTO");
|
||||
const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
|
||||
|
||||
const editFields: ItemFields = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
quantity: String(Number(item.quantity)),
|
||||
unit: item.unit ?? "",
|
||||
locationId: item.locationId ?? "",
|
||||
parentItemId: item.parentItemId ?? "",
|
||||
brand: item.brand ?? "",
|
||||
modelNumber: item.modelNumber ?? "",
|
||||
serial: item.serial ?? "",
|
||||
value: item.value != null ? String(Number(item.value)) : "",
|
||||
purchaseDate: toDateInput(item.purchaseDate),
|
||||
warrantyExpiry: toDateInput(item.warrantyExpiry),
|
||||
barcode: item.barcode ?? "",
|
||||
notes: item.notes ?? "",
|
||||
labelIds: item.labels.map((l) => l.labelId),
|
||||
};
|
||||
|
||||
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
|
||||
if (item.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: item.location.name });
|
||||
if (item.parentItem) facts.push({ icon: <Box className="h-4 w-4" />, label: "Inside", value: <Link className="hover:underline" href={`/inventory/${item.parentItem.id}`}>{item.parentItem.name}</Link> });
|
||||
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
|
||||
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
|
||||
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
|
||||
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
|
||||
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
|
||||
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
|
||||
if (item.barcode) facts.push({ icon: <Barcode className="h-4 w-4" />, label: "Barcode", value: item.barcode });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/inventory" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
|
||||
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
||||
</div>
|
||||
{wStatus !== "none" && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
|
||||
<AddItemLogButton itemId={item.id} />
|
||||
<AttachmentUpload itemId={item.id} />
|
||||
<DeleteItemButton id={item.id} name={item.name} />
|
||||
</div>
|
||||
|
||||
{item.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{item.labels.map((l) => <Badge key={l.labelId} variant="secondary">{l.label.name}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((p) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={p.id} src={p.url} alt={p.name ?? ""} className="h-28 w-full object-cover rounded-lg border" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{facts.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{f.label}</p>
|
||||
<p className="truncate">{f.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.notes && (
|
||||
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
|
||||
)}
|
||||
|
||||
{docs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="font-display text-sm font-semibold">Documents</h2>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{docs.map((d) => (
|
||||
<a key={d.id} href={d.url} target="_blank" rel="noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />{d.name ?? d.kind}
|
||||
<span className="ml-auto text-xs text-muted-foreground">{d.kind}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.containedItems.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="font-display text-lg font-semibold">Contains</h2>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{item.containedItems.map((c) => (
|
||||
<Link key={c.id} href={`/inventory/${c.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
|
||||
<Package className="h-4 w-4 text-[hsl(var(--leaf))]" />{c.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold mb-3">History</h2>
|
||||
{item.logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
|
||||
No history yet — log maintenance, a repair, or a move above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{item.logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${ITEM_LOG_COLORS[log.type]}`}>
|
||||
{ITEM_LOG_LABELS[log.type]}
|
||||
</span>
|
||||
{log.cost != null && <span className="text-xs text-muted-foreground">· {Number(log.cost).toLocaleString(undefined, { style: "currency", currency: "USD" })}</span>}
|
||||
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||
</div>
|
||||
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/app/(dashboard)/inventory/page.tsx
Normal file
62
src/app/(dashboard)/inventory/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { AddItemButton } from "@/components/inventory/item-dialog";
|
||||
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
|
||||
import { totalValue } from "@/lib/inventory/value";
|
||||
|
||||
export const metadata = { title: "Inventory" };
|
||||
|
||||
export default async function InventoryPage() {
|
||||
const [items, locations, labels] = await Promise.all([
|
||||
db.item.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
location: { select: { id: true, name: true } },
|
||||
parentItem: { select: { id: true, name: true } },
|
||||
labels: { include: { label: true } },
|
||||
_count: { select: { containedItems: { where: { active: true } }, logs: true } },
|
||||
},
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.label.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
// Convert Prisma Decimals to plain numbers before handing to client components.
|
||||
const gridItems: GridItem[] = items.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
quantity: Number(i.quantity),
|
||||
unit: i.unit,
|
||||
value: i.value != null ? Number(i.value) : null,
|
||||
warrantyExpiry: i.warrantyExpiry,
|
||||
imageUrl: i.imageUrl,
|
||||
locationId: i.locationId,
|
||||
parentItemId: i.parentItemId,
|
||||
location: i.location,
|
||||
labels: i.labels,
|
||||
_count: i._count,
|
||||
}));
|
||||
|
||||
const total = totalValue(items.map((i) => ({
|
||||
value: i.value != null ? Number(i.value) : null,
|
||||
quantity: Number(i.quantity),
|
||||
})));
|
||||
const itemOpts = items.map((i) => ({ id: i.id, name: i.name }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Inventory</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{items.length} {items.length === 1 ? "item" : "items"}
|
||||
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
|
||||
</p>
|
||||
</div>
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
</div>
|
||||
|
||||
<InventoryGrid items={gridItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
|
||||
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
|
||||
labels, items, labelOnItems, itemAttachments, itemLogs, auditEvents] =
|
||||
await Promise.all([
|
||||
db.location.findMany(),
|
||||
db.plantType.findMany(),
|
||||
@@ -21,13 +22,18 @@ export async function GET() {
|
||||
db.plantLog.findMany(),
|
||||
db.task.findMany(),
|
||||
db.taskCompletion.findMany(),
|
||||
db.label.findMany(),
|
||||
db.item.findMany(),
|
||||
db.labelOnItem.findMany(),
|
||||
db.itemAttachment.findMany(),
|
||||
db.itemLog.findMany(),
|
||||
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
]);
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
|
||||
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "auditEvents"],
|
||||
};
|
||||
|
||||
const zip = zipSync({
|
||||
@@ -41,6 +47,11 @@ export async function GET() {
|
||||
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
|
||||
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
|
||||
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
|
||||
"labels.json": strToU8(JSON.stringify(labels, null, 2)),
|
||||
"items.json": strToU8(JSON.stringify(items, null, 2)),
|
||||
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
|
||||
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
|
||||
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
|
||||
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
|
||||
});
|
||||
|
||||
|
||||
@@ -80,13 +80,23 @@ export async function POST(req: Request) {
|
||||
const plantLogs = asArray("plantLogs.json");
|
||||
const tasks = asArray("tasks.json");
|
||||
const taskCompletions = asArray("taskCompletions.json");
|
||||
const labels = files["labels.json"] ? asArray("labels.json") : [];
|
||||
const items = files["items.json"] ? asArray("items.json") : [];
|
||||
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
|
||||
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
|
||||
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
|
||||
const auditEvents = asArray("auditEvents.json");
|
||||
|
||||
// Restore inside a transaction — wipe then reload in FK order.
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.taskCompletion.deleteMany();
|
||||
await tx.plantLog.deleteMany();
|
||||
await tx.itemLog.deleteMany();
|
||||
await tx.itemAttachment.deleteMany();
|
||||
await tx.labelOnItem.deleteMany();
|
||||
await tx.task.deleteMany();
|
||||
await tx.item.deleteMany();
|
||||
await tx.label.deleteMany();
|
||||
await tx.plant.deleteMany();
|
||||
await tx.plantGroup.deleteMany();
|
||||
await tx.plantType.deleteMany();
|
||||
@@ -104,6 +114,13 @@ export async function POST(req: Request) {
|
||||
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
|
||||
if (plants.length) await tx.plant.createMany({ data: plants as never });
|
||||
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
|
||||
if (labels.length) await tx.label.createMany({ data: labels as never });
|
||||
// Item self-references parentItemId; one createMany statement validates the
|
||||
// FK only after all rows exist — order-independent.
|
||||
if (items.length) await tx.item.createMany({ data: items as never });
|
||||
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
|
||||
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
|
||||
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
|
||||
if (tasks.length) await tx.task.createMany({ data: tasks as never });
|
||||
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
|
||||
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
|
||||
|
||||
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal file
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authenticate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]);
|
||||
|
||||
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
|
||||
// under public/uploads/items (a persistent Docker volume), same as suggestions.
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
|
||||
const item = await db.item.findUnique({ where: { id: params.id }, select: { active: true, imageUrl: true } });
|
||||
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
const kind = String(form.get("kind") ?? "PHOTO").toUpperCase();
|
||||
if (!file) throw new ApiError(400, "No file uploaded");
|
||||
if (!KINDS.has(kind)) throw new ApiError(400, "Invalid attachment kind");
|
||||
|
||||
const safe = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||
const dir = path.join(process.cwd(), "public", "uploads", "items");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, safe), Buffer.from(await file.arrayBuffer()));
|
||||
const base = (process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXTAUTH_URL || "").replace(/\/+$/, "");
|
||||
const url = base ? `${base}/uploads/items/${safe}` : `/uploads/items/${safe}`;
|
||||
|
||||
const attachment = await db.itemAttachment.create({
|
||||
data: { itemId: params.id, kind, url, name: file.name },
|
||||
});
|
||||
|
||||
// First photo becomes the item's thumbnail.
|
||||
if (kind === "PHOTO" && !item.imageUrl) {
|
||||
await db.item.update({ where: { id: params.id }, data: { imageUrl: url } });
|
||||
}
|
||||
await db.auditEvent.create({
|
||||
data: { action: "item.attachment.add", entityId: params.id, actorId: ctx.userId, payload: { kind, via: ctx.via } },
|
||||
});
|
||||
|
||||
return NextResponse.json(attachment, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/items/[id]/logs/route.ts
Normal file
15
src/app/api/v1/items/[id]/logs/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { itemLogInput } from "@/lib/api/schemas";
|
||||
import { addItemLog } from "@/lib/services/items";
|
||||
|
||||
// POST /api/v1/items/:id/logs
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = itemLogInput.parse(await req.json());
|
||||
return NextResponse.json(await addItemLog(ctx, params.id, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
36
src/app/api/v1/items/[id]/route.ts
Normal file
36
src/app/api/v1/items/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { itemUpdateInput } from "@/lib/api/schemas";
|
||||
import { getItem, updateItem, deleteItem } from "@/lib/services/items";
|
||||
|
||||
// GET /api/v1/items/:id
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:read");
|
||||
return NextResponse.json(await getItem(ctx, params.id));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/v1/items/:id
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = itemUpdateInput.parse(await req.json());
|
||||
return NextResponse.json(await updateItem(ctx, params.id, input));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/items/:id (soft-delete; admin only — enforced in the service)
|
||||
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
await deleteItem(ctx, params.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/items/route.ts
Normal file
34
src/app/api/v1/items/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { itemCreateInput } from "@/lib/api/schemas";
|
||||
import { listItems, createItem } from "@/lib/services/items";
|
||||
|
||||
// GET /api/v1/items?q=&locationId=&kind=DURABLE
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:read");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const kindParam = sp.get("kind");
|
||||
const kind = kindParam === "DURABLE" || kindParam === "CONSUMABLE" ? kindParam : undefined;
|
||||
return NextResponse.json(
|
||||
await listItems(ctx, {
|
||||
q: sp.get("q") ?? undefined,
|
||||
locationId: sp.get("locationId") ?? undefined,
|
||||
kind,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/items
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = itemCreateInput.parse(await req.json());
|
||||
return NextResponse.json(await createItem(ctx, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
37
src/app/api/v1/labels/route.ts
Normal file
37
src/app/api/v1/labels/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/v1/labels
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await authenticateRequest(req, "items:read");
|
||||
return NextResponse.json(await db.label.findMany({ orderBy: { name: "asc" } }));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/labels — create (or return existing by name).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const data = createSchema.parse(await req.json());
|
||||
const name = data.name.trim();
|
||||
const existing = await db.label.findUnique({ where: { name } });
|
||||
if (existing) return NextResponse.json(existing);
|
||||
const label = await db.label.create({ data: { name, color: data.color?.trim() || null } });
|
||||
await db.auditEvent.create({
|
||||
data: { action: "label.create", entityId: label.id, actorId: ctx.userId, payload: { name } },
|
||||
});
|
||||
return NextResponse.json(label, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user