Product CRUD
The heart of the app: Create, Read, Update, and Delete products, plus a stock dashboard. Every route sits behind the auth gate you just built.
01 — THE API
The Five Endpoints
A standard REST resource (the standard way to expose create/read/update/delete over HTTP). All of these require a valid session cookie (the requireAuth middleware / JwtAuthGuard from the last page).
REST reference
POST /api/products # create a product
GET /api/products # list all (the stock view)
GET /api/products/:id # read one
PUT /api/products/:id # update details / restock
DELETE /api/products/:id # remove a product02 — VALIDATION
Validate the Input
One Zod schema guards create and update so bad data never reaches the database.
server/src/schemas.ts
export const ProductSchema = z.object({
name: z.string().min(1),
sku: z.string().min(1),
quantity: z.number().int().min(0),
minStock: z.number().int().min(0),
});
// ProductSchema.partial() → for PUT, where any field is optional03 — BACKEND
CRUD Handlers
TypeORM repository calls shown; Mongoose equivalents are in the comments. The checkLowStock() helper is built on the next page.
server/src/products.routes.ts
import { Router } from "express";
import { ProductSchema } from "./schemas";
import { checkLowStock } from "./low-stock";
const router = Router();
const repo = AppDataSource.getRepository(Product);
// CREATE
router.post("/", async (req, res) => {
const parsed = ProductSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
const product = await repo.save(repo.create(parsed.data));
// Mongoose: await ProductModel.create(parsed.data)
await checkLowStock(product);
res.status(201).json(product);
});
// READ all (stock view)
router.get("/", async (_req, res) => {
res.json(await repo.find()); // ProductModel.find()
});
// READ one
router.get("/:id", async (req, res) => {
const p = await repo.findOneBy({ id: req.params.id }); // findById
if (!p) return res.status(404).json({ error: "Not found" });
res.json(p);
});
// UPDATE / restock
router.put("/:id", async (req, res) => {
const data = ProductSchema.partial().parse(req.body);
await repo.update(req.params.id, data); // findByIdAndUpdate
const product = await repo.findOneBy({ id: req.params.id });
if (!product) return res.status(404).json({ error: "Not found" });
await checkLowStock(product); // ← triggers the alert email
res.json(product);
});
// DELETE
router.delete("/:id", async (req, res) => {
await repo.delete(req.params.id); // findByIdAndDelete
res.status(204).end();
});
export default router;
// mount it: app.use("/api/products", requireAuth, router);04 — FRONTEND
The Stock Dashboard
Fetch the list, show it in a table, and add a form to create products. Remember credentials: "include" on every request so the cookie rides along.
web/app/products/page.tsx ("use client")
"use client";
import { useEffect, useState } from "react";
const API = "http://localhost:4000/api";
const get = (p: string) => fetch(API + p, { credentials: "include" }).then(r => r.json());
export default function Products() {
const [items, setItems] = useState<any[]>([]);
useEffect(() => { get("/products").then(setItems); }, []);
return (
<table className="w-full text-left">
<thead><tr><th>Name</th><th>SKU</th><th>Qty</th><th>Min</th></tr></thead>
<tbody>
{items.map(p => (
<tr key={p.id} className={p.quantity <= p.minStock ? "text-red-500" : ""}>
<td>{p.name}</td><td>{p.sku}</td><td>{p.quantity}</td><td>{p.minStock}</td>
</tr>
))}
</tbody>
</table>
);
}add a product
async function addProduct(body: object) {
await fetch(API + "/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body), // { name, sku, quantity, minStock }
});
}- ✓Create returns 201 and the product appears in the list
- ✓Update changes the values; restocking reflects immediately
- ✓Delete removes the row (204 No Content)
- ✓Low-stock rows render in red when
quantity ≤ minStock